From f80734a80c93f7b360710a4a57241e12138c7184 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 14 Jul 2026 20:00:06 +0000 Subject: [PATCH] Phase 21N.1 - Story Intelligence Payload and Resume Repair --- PlotLine.Tests/Program.cs | 81 +++++++++- PlotLine/Controllers/OnboardingController.cs | 2 + ...elligenceIllustrationMatchingRepository.cs | 22 ++- PlotLine/Program.cs | 23 ++- .../OnboardingStoryIntelligenceService.cs | 44 ++++-- ...IntelligenceIllustrationMatchingService.cs | 39 ++++- ...ntelligenceVisualisationSnapshotService.cs | 148 +++++++++++++++++- ...telligenceExperiencePrototypeViewModels.cs | 18 +-- .../StoryIntelligenceExperience.cshtml | 21 ++- ...story-intelligence-experience-prototype.js | 20 ++- 10 files changed, 376 insertions(+), 42 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 6562e8d..38c7831 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -1,6 +1,9 @@ using System.Text.Json; using System.Net; using System.Text; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using PlotLine.Controllers; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using PlotLine.Models; @@ -48,7 +51,11 @@ var tests = new (string Name, Action Test)[] ("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches), ("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate), ("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage), - ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes) + ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes), + ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), + ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), + ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), + ("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence) }; foreach (var test in tests) @@ -746,6 +753,78 @@ static void IllustrationProviderOmitsGptImageResponseFormat() Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message."); } +static void ScanReviewPostSupportsFullBookFormSubmissions() +{ + var method = typeof(OnboardingController).GetMethod(nameof(OnboardingController.SaveScanReview)); + Assert(method is not null, "Scan review post action could not be found."); + + var formLimits = method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: false) + .OfType() + .SingleOrDefault(); + Assert(formLimits is not null, "Scan review post action should configure expanded form limits."); + Assert(formLimits!.ValueCountLimit >= 20000, "Scan review form value limit should support full-book previews."); + Assert(formLimits.KeyLengthLimit >= 4096, "Scan review form key length limit should support indexed review fields."); + + var requestSize = method.GetCustomAttributes(typeof(RequestSizeLimitAttribute), inherit: false) + .OfType() + .SingleOrDefault(); + Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews."); +} + +static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel() +{ + var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml")); + Assert(!view.Contains("JsonSerializer.Serialize(Model", StringComparison.Ordinal), "Live experience page should not serialise the full view model into the Razor response."); + Assert(view.Contains("JsonSerializer.Serialize(bootModel", StringComparison.Ordinal), "Live experience page should serialise only boot data."); +} + +static void LiveVisualisationStripsIllustrationDiagnostics() +{ + var large = new string('x', 200_000); + var model = new StoryIntelligenceExperiencePrototypeViewModel + { + Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics + { + CharacterConsistencyReport = large + }, + Scenes = + [ + new StoryIntelligenceExperienceSceneState + { + Id = "scene-result-1", + Location = new StoryIntelligenceExperienceLocationState(), + Characters = + [ + new StoryIntelligenceExperienceCharacterState + { + Id = "character-result-beth", + Name = "Beth", + ImageResolution = new StoryIntelligenceExperienceImageResolution + { + RejectedCandidates = large, + PreviousEvidence = large, + CurrentEvidence = large, + EvidenceWarnings = large + } + } + ] + } + ] + }; + + StoryIntelligenceVisualisationSnapshotService.StripLiveDiagnostics(model); + var json = JsonSerializer.Serialize(model, JsonOptions()); + Assert(!json.Contains(large, StringComparison.Ordinal), "Live snapshot should not contain large illustration diagnostic strings."); + Assert(json.Length < 20_000, $"Live snapshot remained too large after stripping diagnostics: {json.Length:N0} chars."); +} + +static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence() +{ + var source = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs")); + Assert(!source.Contains("previousStoredEvidence = existing?.EvidenceJson", StringComparison.Ordinal), "Evidence trace must not recursively embed previous EvidenceJson."); + Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash."); +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 6f8433d..d2dad52 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -490,6 +490,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard } [HttpPost("scan-review")] + [RequestSizeLimit(25 * 1024 * 1024)] + [RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)] [ValidateAntiForgeryToken] public async Task SaveScanReview(ManuscriptScanReviewForm form, string intent = "save") { diff --git a/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs b/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs index fa54fe8..3af455f 100644 --- a/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs +++ b/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs @@ -17,7 +17,27 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( """ - SELECT * + SELECT + StoryIntelligenceCharacterIllustrationAssignmentID, + ProjectID, + CharacterKey, + CharacterName, + IllustrationLibraryItemID, + StableCode, + MatchConfidence, + MatchingScore, + CAST(NULL AS nvarchar(max)) AS EvidenceJson, + CAST(NULL AS nvarchar(max)) AS CandidateDiagnosticsJson, + ReasonChosen, + ReasonReassigned, + IsActive, + CreatedUtc, + UpdatedUtc, + AssignmentStatus, + AssignedUtc, + LastValidatedUtc, + EvidenceVersion, + IsFallback FROM dbo.StoryIntelligenceCharacterIllustrationAssignments WHERE ProjectID = @ProjectID AND IsActive = 1; diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 6f6cebb..0724357 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -47,8 +47,12 @@ public class Program builder.Configuration.GetSection("StoryIntelligence").Bind(options); }); builder.Services.Configure(builder.Configuration.GetSection("StoryIntelligence:Pricing")); + var dataProtectionKeyPath = GetDataProtectionKeyPath( + builder.Environment.ContentRootPath, + builder.Configuration["DataProtection:KeysPath"]); + Directory.CreateDirectory(dataProtectionKeyPath); builder.Services.AddDataProtection() - .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); + .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyPath)); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { @@ -275,4 +279,21 @@ public class Program app.Run(); } + + private static string GetDataProtectionKeyPath(string contentRootPath, string? configuredPath) + { + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + return Path.GetFullPath(configuredPath); + } + + var contentRoot = new DirectoryInfo(contentRootPath); + if (string.Equals(contentRoot.Name, "current", StringComparison.OrdinalIgnoreCase) + && contentRoot.Parent is not null) + { + return Path.Combine(contentRoot.Parent.FullName, "App_Data", "DataProtectionKeys"); + } + + return Path.Combine(contentRoot.FullName, "App_Data", "DataProtectionKeys"); + } } diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index e21ca2d..949dc11 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -579,8 +579,35 @@ public sealed class OnboardingStoryIntelligenceService( return null; } - var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId); - if (committedRuns.Count == 0) + var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId)) + .OrderBy(run => run.ChapterNumber) + .ThenBy(run => run.StoryIntelligenceRunID) + .Select(run => new OnboardingStoryIntelligenceBatchItem + { + TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}", + ChapterNumber = Convert.ToInt32(run.ChapterNumber), + ChapterTitle = run.ChapterTitle, + ChapterID = run.ChapterID, + RunID = run.StoryIntelligenceRunID + }) + .ToList(); + var activeRuns = committedRuns.Count > 0 + ? [] + : (await runs.ListRunsByBookForUserAsync(bookId, userId)) + .Where(run => run.BookID == state.BookID) + .OrderBy(run => run.ChapterNumber ?? decimal.MaxValue) + .ThenBy(run => run.StoryIntelligenceRunID) + .Select(run => new OnboardingStoryIntelligenceBatchItem + { + TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}", + ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0), + ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!, + ChapterID = run.ChapterID ?? 0, + RunID = run.StoryIntelligenceRunID + }) + .ToList(); + var batchItems = committedRuns.Count > 0 ? committedRuns : activeRuns; + if (batchItems.Count == 0) { return null; } @@ -594,18 +621,7 @@ public sealed class OnboardingStoryIntelligenceService( BookID = state.BookID, ProjectName = state.ProjectName, BookTitle = state.BookDisplayTitle, - Items = committedRuns - .OrderBy(run => run.ChapterNumber) - .ThenBy(run => run.StoryIntelligenceRunID) - .Select(run => new OnboardingStoryIntelligenceBatchItem - { - TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}", - ChapterNumber = Convert.ToInt32(run.ChapterNumber), - ChapterTitle = run.ChapterTitle, - ChapterID = run.ChapterID, - RunID = run.StoryIntelligenceRunID - }) - .ToList() + Items = batchItems }; var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport) diff --git a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs index 30be30e..fe584ba 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs @@ -246,16 +246,45 @@ public sealed class StoryIntelligenceIllustrationMatchingService( character = observation.Name, identityKey = observation.CharacterKey, canonicalIdentity = observation.CharacterKey, - rawExtractedEvidence = observation.IdentityEvidence, - sceneEvidence = observation.TextEvidence, - relationshipEvidence = observation.RelationshipEvidence, - mergedEvidence = observation.TextEvidence.Concat(observation.RelationshipEvidence).ToArray(), - previousStoredEvidence = existing?.EvidenceJson, + rawExtractedEvidence = CompactEvidence(observation.IdentityEvidence), + sceneEvidence = CompactEvidence(observation.TextEvidence), + relationshipEvidence = CompactEvidence(observation.RelationshipEvidence), + mergedEvidence = CompactEvidence(observation.TextEvidence.Concat(observation.RelationshipEvidence)), + previousAssignment = existing is null + ? null + : new + { + existing.StableCode, + existing.IllustrationLibraryItemID, + existing.MatchConfidence, + existing.MatchingScore, + evidenceChars = existing.EvidenceJson?.Length ?? 0, + evidenceHash = string.IsNullOrWhiteSpace(existing.EvidenceJson) ? null : StableHash(existing.EvidenceJson) + }, finalMatcherEvidence = observation.Evidence, evidenceSource = "Story Intelligence visualisation character state", canonicalMergeReason = "Character state ID from canonical visualisation identity" }, JsonOptions); + private static IReadOnlyList CompactEvidence(IEnumerable evidence) + => evidence + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => Trim(value, 160)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(16) + .ToList(); + + private static string Trim(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var clean = Regex.Replace(value.Trim(), @"\s+", " "); + return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd() + "..."; + } + private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand) { var age = NormaliseUnknown(demand.AgeBand, "YoungAdult"); diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index b2e3c9f..63b465e 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -25,6 +25,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( ILogger logger) : IStoryIntelligenceVisualisationSnapshotService { private const string FallbackRoot = "/images/story-intelligence/prototype"; + private const int SceneWindowPrevious = 4; + private const int SceneWindowFollowing = 2; + private const int SnapshotWarningBytes = 1024 * 1024; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -116,7 +119,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( var current = PickCurrentScene(run, completed); var storyMemory = new StoryMemory(); - var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory).ToList(); + var visibleSceneIds = VisibleSceneResultIds(completed, current, SceneWindowPrevious, SceneWindowFollowing); + var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory, includedSceneResultIds: visibleSceneIds).ToList(); if (scenes.Count == 0) { scenes.Add(BuildEmptyScene(run)); @@ -144,7 +148,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model); ApplyIllustrationDiagnostics(model); } - logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", runId, stopwatch.ElapsedMilliseconds); + StripLiveDiagnostics(model); + LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds); return model; } @@ -209,6 +214,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( var scenes = new List(); var nextSceneNumber = 1; var storyMemory = new StoryMemory(); + var allCompleted = runSnapshots.SelectMany(snapshot => snapshot.Completed).ToList(); + var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, SceneWindowFollowing); foreach (var snapshot in runSnapshots) { var runScenes = BuildSceneStates( @@ -227,7 +234,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( overallRemaining: overallRemaining, continuousSceneNumbering: true, overallTerminal: isTerminal, - storyMemory: storyMemory) + storyMemory: storyMemory, + includedSceneResultIds: visibleSceneIds) .ToList(); scenes.AddRange(runScenes); nextSceneNumber += snapshot.Completed.Count; @@ -272,7 +280,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model); ApplyIllustrationDiagnostics(model); - logger.LogInformation("Built Story Intelligence import session visualisation snapshot for session {ImportSessionId} in {ElapsedMs}ms.", importSessionId, stopwatch.ElapsedMilliseconds); + StripLiveDiagnostics(model); + LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds); return model; } @@ -292,7 +301,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( string? overallRemaining = null, bool continuousSceneNumbering = false, bool overallTerminal = false, - StoryMemory? storyMemory = null) + StoryMemory? storyMemory = null, + IReadOnlySet? includedSceneResultIds = null) { storyMemory ??= new StoryMemory(); var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count); @@ -304,6 +314,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( storyMemory.Update(scene, item); var sourceSceneNumber = SceneNumber(item); var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber; + if (includedSceneResultIds is not null && !includedSceneResultIds.Contains(item.Result.SceneResultID)) + { + continue; + } + var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID; yield return new StoryIntelligenceExperienceSceneState { @@ -1504,6 +1519,129 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( })); } + public static void StripLiveDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model) + { + model.Diagnostics.CharacterConsistencyReport = Trim(model.Diagnostics.CharacterConsistencyReport, 2000); + foreach (var resolution in model.Scenes + .SelectMany(scene => scene.Characters.Select(character => character.ImageResolution) + .Concat(scene.Assets.Select(asset => asset.ImageResolution)) + .Append(scene.Location.ImageResolution))) + { + StripLiveDiagnostics(resolution); + } + } + + private static void StripLiveDiagnostics(StoryIntelligenceExperienceImageResolution resolution) + { + if (resolution is null) + { + return; + } + + resolution.RejectedCandidates = null; + resolution.PreviousEvidence = null; + resolution.CurrentEvidence = null; + resolution.EvidenceWarnings = null; + resolution.ReasonChosen = Trim(resolution.ReasonChosen, 160); + resolution.ReasonReassigned = Trim(resolution.ReasonReassigned, 160); + resolution.PreviousIllustration = Trim(resolution.PreviousIllustration, 80); + resolution.DemandKey = Trim(resolution.DemandKey, 80); + resolution.DemandStatus = Trim(resolution.DemandStatus, 80); + } + + private static IReadOnlySet VisibleSceneResultIds( + IReadOnlyList completed, + ParsedSceneResult? current, + int previousCount = SceneWindowPrevious, + int followingCount = SceneWindowFollowing) + { + if (completed.Count == 0) + { + return new HashSet(); + } + + var currentIndex = current is null + ? completed.Count - 1 + : completed.ToList().FindIndex(item => item.Result.SceneResultID == current.Result.SceneResultID); + if (currentIndex < 0) + { + currentIndex = completed.Count - 1; + } + + var start = Math.Max(0, currentIndex - previousCount); + var end = Math.Min(completed.Count - 1, currentIndex + followingCount); + return completed + .Skip(start) + .Take(end - start + 1) + .Select(item => item.Result.SceneResultID) + .ToHashSet(); + } + + private void LogPayload(StoryIntelligenceExperiencePrototypeViewModel model, string scope, long elapsedMs) + { + var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(model, JsonOptions).Length; + var counts = new + { + sceneCount = model.Scenes.Count, + characterCount = model.Scenes.Sum(scene => scene.Characters.Count), + locationCount = model.Scenes.Count(scene => !string.IsNullOrWhiteSpace(scene.Location.Name)), + assetCount = model.Scenes.Sum(scene => scene.Assets.Count), + relationshipCount = model.Scenes.Sum(scene => scene.Relationships.Count), + knowledgeCount = model.Scenes.Sum(scene => scene.KnowledgeThreads.Count), + observationCount = model.Scenes.Sum(scene => scene.Observations.Count), + activityCount = model.Scenes.Sum(scene => scene.ActivityFeed.Count) + }; + var largestString = LargestStringProperty(model); + if (payloadBytes > SnapshotWarningBytes) + { + logger.LogWarning( + "Story Intelligence snapshot payload for {Scope} is {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters; built in {ElapsedMs}ms.", + scope, + payloadBytes, + counts, + largestString.Path, + largestString.Length, + elapsedMs); + return; + } + + logger.LogInformation( + "Built Story Intelligence visualisation snapshot for {Scope} in {ElapsedMs}ms; payload {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters.", + scope, + elapsedMs, + payloadBytes, + counts, + largestString.Path, + largestString.Length); + } + + private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model) + { + var largest = ("model", 0); + void Consider(string path, string? value) + { + if (value?.Length > largest.Item2) + { + largest = (path, value.Length); + } + } + + Consider("snapshotMessage", model.SnapshotMessage); + Consider("diagnostics.characterConsistencyReport", model.Diagnostics.CharacterConsistencyReport); + foreach (var scene in model.Scenes) + { + Consider($"{scene.Id}.summary", scene.Summary); + foreach (var character in scene.Characters) + { + Consider($"{scene.Id}.character.{character.Id}.name", character.Name); + Consider($"{scene.Id}.character.{character.Id}.relevance", character.Relevance); + Consider($"{scene.Id}.character.{character.Id}.rejectedCandidates", character.ImageResolution.RejectedCandidates); + } + } + + return (largest.Item1, largest.Item2); + } + private static bool IsTerminal(string status) => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled; diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index 12bf41a..ad2cf49 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -148,15 +148,15 @@ public sealed class StoryIntelligenceExperienceImageResolution public bool FallbackUsed { get; init; } = true; public decimal? AssignmentConfidence { get; init; } public int? MatchingScore { get; init; } - public string? RejectedCandidates { get; init; } - public string? ReasonChosen { get; init; } - public string? ReasonReassigned { get; init; } - public string? PreviousIllustration { get; init; } - public string? PreviousEvidence { get; init; } - public string? CurrentEvidence { get; init; } - public string? DemandKey { get; init; } - public string? EvidenceWarnings { get; init; } - public string? DemandStatus { get; init; } + public string? RejectedCandidates { get; set; } + public string? ReasonChosen { get; set; } + public string? ReasonReassigned { get; set; } + public string? PreviousIllustration { get; set; } + public string? PreviousEvidence { get; set; } + public string? CurrentEvidence { get; set; } + public string? DemandKey { get; set; } + public string? EvidenceWarnings { get; set; } + public string? DemandStatus { get; set; } public string? ResolvedPresentation { get; init; } public string? ResolvedAgeBand { get; init; } public decimal? RelationshipDerivedConfidence { get; init; } diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 93d36a4..a4a27d5 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -18,6 +18,21 @@ "error" => "No live snapshot", _ => "Choose a session" }; + var bootModel = Model.Mode == "real" + ? new StoryIntelligenceExperiencePrototypeViewModel + { + Mode = Model.Mode, + RunId = Model.RunId, + ImportSessionId = Model.ImportSessionId, + RunStatus = Model.RunStatus, + GeneratedUtc = Model.GeneratedUtc, + IsTerminal = false, + ProjectName = Model.ProjectName, + BookTitle = Model.BookTitle, + SnapshotMessage = Model.SnapshotMessage, + Diagnostics = Model.Diagnostics + } + : Model; } @@ -28,7 +43,7 @@ - +
+ data-change-token="@(Model.Mode == "real" ? string.Empty : Model.ChangeToken)" + data-terminal="@((Model.Mode != "real" && Model.IsTerminal).ToString().ToLowerInvariant())">
PlotDirector diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index cd92183..c68aa02 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -77,8 +77,9 @@ }; if (!scenes.length) { + state.changeToken = ""; + state.currentSnapshot = { ...model, scenes: [] }; updateDiagnostics(); - return; } function loadSceneState(index) { @@ -126,6 +127,12 @@ scenes = model.scenes || []; state.changeToken = model.changeToken || state.changeToken; state.terminal = !!model.isTerminal; + if (!scenes.length) { + updateDiagnostics(); + if (state.terminal) stopPolling("terminal model received"); + return; + } + state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1)); if (model.projectName) { text(dom.projectName, model.projectName); @@ -1065,6 +1072,7 @@ } function startTimer() { + if (!scenes.length) return; stopTimer(); if (!state.playing || !simulationMode) return; state.timer = window.setInterval(() => { @@ -1384,6 +1392,7 @@ }); window.addEventListener("resize", () => { + if (!scenes.length) return; const current = loadSceneState(state.index); const layout = sceneLayout(current); reconcileCharacters(current, current, layout); @@ -1394,6 +1403,7 @@ if (window.ResizeObserver && dom.visualStage) { const observer = new ResizeObserver(() => { + if (!scenes.length) return; const current = loadSceneState(state.index); const layout = sceneLayout(current); reconcileCharacters(current, current, layout); @@ -1403,8 +1413,12 @@ observer.observe(dom.visualStage); } - transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0); - startTimer(); + if (scenes.length) { + transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0); + startTimer(); + } else { + updateDiagnostics(); + } startPolling(); if (realMode && !state.terminal) pollSnapshot(); })();