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 runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines),
|
||||
("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys),
|
||||
("Story Intelligence stabilisation gate materialises strict and legacy metrics", StoryIntelligenceStabilisationGateMaterialisesStrictAndLegacyMetrics),
|
||||
("Story Intelligence modern materialisation owns finalisation boundary", StoryIntelligenceModernMaterialisationOwnsFinalisationBoundary),
|
||||
("Story Intelligence narrative materialisation reads strict scene results", StoryIntelligenceNarrativeMaterialisationReadsStrictSceneResults),
|
||||
("Story Intelligence stabilisation routes normal users to book", StoryIntelligenceStabilisationRoutesNormalUsersToBook),
|
||||
("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview),
|
||||
("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration),
|
||||
("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates),
|
||||
@ -469,6 +473,7 @@ static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
|
||||
{
|
||||
var root = PlotLineRoot();
|
||||
var migration = File.ReadAllText(Path.Combine(root, "Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql"));
|
||||
var materialisationMigration = File.ReadAllText(Path.Combine(root, "Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql"));
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs"));
|
||||
var pipelineState = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs"));
|
||||
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
|
||||
@ -486,12 +491,12 @@ static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
|
||||
Assert(migration.Contains("@CompletedScenes + @FailedScenes >= @TotalExpectedScenes", StringComparison.Ordinal), "All expected scenes should be terminal before review is released.");
|
||||
Assert(!migration.Contains("StoryIntelligenceImportCommits", StringComparison.Ordinal), "Canonical scene-analysis completion should not depend on legacy import commit rows.");
|
||||
Assert(migration.Contains("AND Status = N'InProgress'", StringComparison.Ordinal), "The finaliser update should be idempotent and claim only an active pipeline.");
|
||||
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryCompleteNextReadySceneAnalysis", StringComparison.Ordinal), "Idle workers should recover books stranded after restart before finalisation.");
|
||||
Assert(materialisationMigration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis", StringComparison.Ordinal), "Idle workers should find books stranded after restart without finalising before materialisation.");
|
||||
|
||||
Assert(repository.Contains("TryCompleteSceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the per-book barrier procedure.");
|
||||
Assert(repository.Contains("TryCompleteNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the restart recovery barrier procedure.");
|
||||
Assert(repository.Contains("FindNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the restart recovery lookup procedure.");
|
||||
Assert(pipelineState.Contains("CompleteSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Pipeline state service should complete scene analysis after the final chapter run.");
|
||||
Assert(pipelineState.Contains("RecoverNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Pipeline state service should support restart-safe recovery.");
|
||||
Assert(pipelineState.Contains("FindNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Pipeline state service should support restart-safe recovery without completing before materialisation.");
|
||||
Assert(runner.Contains("CompleteBookSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Runner should check the book barrier after chapter completion.");
|
||||
Assert(runner.Contains("await CompleteBookSceneAnalysisIfReadyAsync(run, cancellationToken);", StringComparison.Ordinal), "Runner should use the barrier instead of emailing while the pipeline is still InProgress.");
|
||||
Assert(worker.Contains("RecoverReadySceneAnalysisAsync", StringComparison.Ordinal), "Idle worker polling should repair ready pipelines even without new scene events.");
|
||||
@ -4697,14 +4702,153 @@ static void StoryIntelligenceMetricPersistenceUsesConfiguredKeys()
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
|
||||
var materialisation = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceMaterialisationService.cs"));
|
||||
|
||||
Assert(service.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID.");
|
||||
Assert(service.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names.");
|
||||
Assert(service.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale.");
|
||||
Assert(materialisation.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID.");
|
||||
Assert(materialisation.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names.");
|
||||
Assert(materialisation.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale.");
|
||||
Assert(service.Contains("StoryIntelligenceMaterialisationService.BuildMetrics", StringComparison.Ordinal), "Legacy admin commit should reuse the modern-neutral metric converter.");
|
||||
Assert(repository.Contains("\"dbo.SceneMetric_SaveValue\"", StringComparison.Ordinal), "Metric values should persist through the existing stored procedure.");
|
||||
Assert(repository.Contains("new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }", StringComparison.Ordinal), "Metrics should persist by SceneID and MetricTypeID.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceStabilisationGateMaterialisesStrictAndLegacyMetrics()
|
||||
{
|
||||
var metricTypes = new[]
|
||||
{
|
||||
new SceneMetricType { MetricTypeID = 1, MetricName = "Overall Intensity", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 2, MetricName = "Tension", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 3, MetricName = "Emotional Weight", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 4, MetricName = "Action", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 5, MetricName = "Darkness", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 6, MetricName = "Romance", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 7, MetricName = "Sexual Charge", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 8, MetricName = "Violence", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 9, MetricName = "Hope / Lightness", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 10, MetricName = "Comedy", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true },
|
||||
new SceneMetricType { MetricTypeID = 11, MetricName = "Mystery", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }
|
||||
};
|
||||
var map = StoryIntelligenceMaterialisationService.BuildMetricMap(metricTypes);
|
||||
var strictScene = new SceneIntelligenceScene
|
||||
{
|
||||
MetricValues =
|
||||
[
|
||||
new SceneIntelligenceMetricValue { Key = "metric_1", Score = 8, Confidence = 0.91m },
|
||||
new SceneIntelligenceMetricValue { Key = "metric_2", Score = 7, Confidence = 0.85m },
|
||||
new SceneIntelligenceMetricValue { Key = "metric_3", Score = 12, Confidence = 0.8m }
|
||||
],
|
||||
Metrics = new Dictionary<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()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
@ -5037,9 +5181,11 @@ static void StoryIntelligenceStructuredMetricValuesValidateAndImport()
|
||||
|
||||
Assert(validation.IsValid, string.Join("; ", validation.Errors.Select(error => $"{error.Path}: {error.Message}")));
|
||||
|
||||
var materialisation = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceMaterialisationService.cs"));
|
||||
var commit = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceImportCommitService.cs"));
|
||||
Assert(commit.Contains("parsed.MetricValues", StringComparison.Ordinal), "Import should normalise strict metricValues for existing metric persistence.");
|
||||
Assert(commit.Contains("metrics[metric.Key.Trim()]", StringComparison.Ordinal), "Metric normalisation should key values by configured metric key.");
|
||||
Assert(materialisation.Contains("parsed.MetricValues", StringComparison.Ordinal), "Modern materialisation should normalise strict metricValues for persistence.");
|
||||
Assert(materialisation.Contains("metric.Key?.Trim()", StringComparison.Ordinal), "Metric normalisation should key values by configured metric key.");
|
||||
Assert(commit.Contains("StoryIntelligenceMaterialisationService.BuildMetrics", StringComparison.Ordinal), "Legacy admin commit should delegate metric conversion to modern materialisation helpers.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceStructuredSchemaCoversCompleteResponseModel()
|
||||
|
||||
@ -86,7 +86,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
var model = await storyIntelligence.GetOverviewAsync();
|
||||
if (model.ExistingBatchID.HasValue)
|
||||
{
|
||||
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = model.ExistingBatchID.Value });
|
||||
return RedirectToBook(model);
|
||||
}
|
||||
|
||||
if (model.PreviewID == Guid.Empty)
|
||||
@ -142,7 +142,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID });
|
||||
var progress = await storyIntelligence.GetProgressAsync(job.BatchID);
|
||||
if (progress is not null)
|
||||
{
|
||||
TempData["ArchiveMessage"] = "Story Intelligence is now analysing your book. Progress is shown in the page header.";
|
||||
return RedirectToBook(progress);
|
||||
}
|
||||
|
||||
return RedirectToAction("Details", "Books", new { id = job.BookID });
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/book/{bookId:int}/start")]
|
||||
@ -598,6 +605,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
|
||||
=> RedirectToAction("Details", "Books", new { id = model.BookID });
|
||||
|
||||
private IActionResult RedirectToBook(StoryIntelligenceOverviewViewModel model)
|
||||
=> RedirectToAction("Details", "Books", new { id = model.BookID });
|
||||
|
||||
private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId)
|
||||
{
|
||||
TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category.";
|
||||
|
||||
@ -12,8 +12,8 @@ public interface IStoryIntelligencePipelineRepository
|
||||
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
|
||||
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
|
||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||
Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync();
|
||||
Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(int bookId, int userId);
|
||||
Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteNextReadySceneAnalysisAsync();
|
||||
Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId);
|
||||
}
|
||||
|
||||
@ -85,6 +85,14 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
|
||||
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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
@ -94,14 +102,6 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
|
||||
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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -107,6 +107,19 @@ public sealed class StoryIntelligenceSceneAnalysisCompletion
|
||||
public bool CompletedNow { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceSceneAnalysisRecoveryCandidate
|
||||
{
|
||||
public int StoryIntelligenceBookPipelineID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public int UserID { get; init; }
|
||||
public int? LastRunID { get; init; }
|
||||
public int RunCount { get; init; }
|
||||
public int TotalExpectedScenes { get; init; }
|
||||
public int CompletedScenes { get; init; }
|
||||
public int FailedScenes { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceBookPipelineSaveRequest
|
||||
{
|
||||
public int ProjectID { get; init; }
|
||||
|
||||
@ -235,6 +235,7 @@ public class Program
|
||||
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
|
||||
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceMaterialisationService, StoryIntelligenceMaterialisationService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceGlobalProgressService, StoryIntelligenceGlobalProgressService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>();
|
||||
@ -262,7 +263,6 @@ public class Program
|
||||
builder.Services.AddHostedService<EmailQueueWorker>();
|
||||
if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true))
|
||||
{
|
||||
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
||||
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
||||
}
|
||||
if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true))
|
||||
|
||||
@ -29,6 +29,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
IStoryIntelligenceProgressNotifier notifier,
|
||||
IStoryMemoryService storyMemory,
|
||||
ICharacterIntelligenceService characterIntelligence,
|
||||
IStoryIntelligenceMaterialisationService materialisation,
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
IStoryIntelligenceEntitlementService entitlement,
|
||||
IStoryIntelligenceCompletionNotificationService completionNotifications,
|
||||
@ -791,6 +792,17 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
return;
|
||||
}
|
||||
|
||||
var materialised = await materialisation.MaterialiseReadyBookAsync(bookId, run.UserID, cancellationToken);
|
||||
if (!materialised.IsReady)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Story Intelligence finalisation deferred for BookID={BookID}. RunID={RunID}. Reason={Reason}",
|
||||
bookId,
|
||||
run.StoryIntelligenceRunID,
|
||||
materialised.Reason);
|
||||
return;
|
||||
}
|
||||
|
||||
var completion = await pipelineState.CompleteSceneAnalysisIfReadyAsync(bookId, run.UserID);
|
||||
if (completion is { CompletedNow: true })
|
||||
{
|
||||
|
||||
@ -60,9 +60,26 @@ public sealed class PersistedStoryIntelligenceWorker(
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var pipelineState = scope.ServiceProvider.GetRequiredService<IStoryIntelligencePipelineStateService>();
|
||||
var materialisation = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceMaterialisationService>();
|
||||
var notifier = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceProgressNotifier>();
|
||||
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 })
|
||||
{
|
||||
return;
|
||||
|
||||
@ -29,21 +29,6 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
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)
|
||||
{
|
||||
var prepared = await PrepareAsync(runId);
|
||||
@ -434,95 +419,11 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<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, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes)
|
||||
=> StoryIntelligenceMaterialisationService.BuildMetricMap(metricTypes);
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
=> StoryIntelligenceMaterialisationService.BuildMetrics(parsed, metricMap);
|
||||
|
||||
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
|
||||
{
|
||||
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<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)
|
||||
{
|
||||
await EnsureCandidatesAsync(batch);
|
||||
@ -282,7 +287,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
CandidatesRejected = batch.LastNarrativeImportResult?.CandidatesRejected ?? 0
|
||||
});
|
||||
|
||||
private async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
public async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
|
||||
var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID);
|
||||
@ -604,7 +609,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
try
|
||||
{
|
||||
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions);
|
||||
if (direct?.Summary is not null)
|
||||
if (direct is not null && IsReadableSceneResult(direct))
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
@ -624,6 +629,15 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsReadableSceneResult(SceneIntelligenceScene scene)
|
||||
=> !string.IsNullOrWhiteSpace(scene.SchemaVersion)
|
||||
|| scene.Summary is not null
|
||||
|| scene.NarrativeArcs?.Count > 0
|
||||
|| scene.QuestionsRaised?.Count > 0
|
||||
|| scene.TimelineClues?.Count > 0
|
||||
|| scene.MetricValues?.Count > 0
|
||||
|| scene.Metrics?.Count > 0;
|
||||
|
||||
private static string CleanTitle(string? preferred, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim();
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ public interface IStoryIntelligencePipelineStateService
|
||||
Task RecordKnowledgeImportAsync(int projectId, int bookId);
|
||||
Task RecordNarrativeImportAsync(int projectId, int bookId);
|
||||
Task<StoryIntelligenceSceneAnalysisCompletion?> CompleteSceneAnalysisIfReadyAsync(int bookId, int userId);
|
||||
Task<StoryIntelligenceSceneAnalysisCompletion?> RecoverNextReadySceneAnalysisAsync();
|
||||
Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync();
|
||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||
}
|
||||
|
||||
@ -194,12 +194,8 @@ public sealed class StoryIntelligencePipelineStateService(
|
||||
return completion;
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceSceneAnalysisCompletion?> RecoverNextReadySceneAnalysisAsync()
|
||||
{
|
||||
var completion = await pipelines.TryCompleteNextReadySceneAnalysisAsync();
|
||||
LogSceneAnalysisCompletion(completion, "recovered");
|
||||
return completion;
|
||||
}
|
||||
public Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync()
|
||||
=> pipelines.FindNextReadySceneAnalysisAsync();
|
||||
|
||||
public Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int 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>
|
||||
<p>You can return to progress or open the Review Centre when analysis is ready.</p>
|
||||
<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">
|
||||
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
|
||||
</form>
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<h2>Analysis has already started</h2>
|
||||
<p>You can return to progress or open the Review Centre when analysis is ready.</p>
|
||||
<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">
|
||||
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
|
||||
</form>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user