Phase 21N.1 - Story Intelligence Payload and Resume Repair

This commit is contained in:
Nick Beckley 2026-07-14 20:00:06 +00:00
parent a54da80ea2
commit f80734a80c
10 changed files with 376 additions and 42 deletions

View File

@ -1,6 +1,9 @@
using System.Text.Json; using System.Text.Json;
using System.Net; using System.Net;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Controllers;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PlotLine.Models; 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 semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate), ("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage), ("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) 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."); 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<RequestFormLimitsAttribute>()
.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<RequestSizeLimitAttribute>()
.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() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -490,6 +490,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
} }
[HttpPost("scan-review")] [HttpPost("scan-review")]
[RequestSizeLimit(25 * 1024 * 1024)]
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save") public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
{ {

View File

@ -17,7 +17,27 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>( var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
""" """
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 FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID WHERE ProjectID = @ProjectID
AND IsActive = 1; AND IsActive = 1;

View File

@ -47,8 +47,12 @@ public class Program
builder.Configuration.GetSection("StoryIntelligence").Bind(options); builder.Configuration.GetSection("StoryIntelligence").Bind(options);
}); });
builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing")); builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
var dataProtectionKeyPath = GetDataProtectionKeyPath(
builder.Environment.ContentRootPath,
builder.Configuration["DataProtection:KeysPath"]);
Directory.CreateDirectory(dataProtectionKeyPath);
builder.Services.AddDataProtection() builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyPath));
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options => .AddCookie(options =>
{ {
@ -275,4 +279,21 @@ public class Program
app.Run(); 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");
}
} }

View File

@ -579,8 +579,35 @@ public sealed class OnboardingStoryIntelligenceService(
return null; return null;
} }
var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId); var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
if (committedRuns.Count == 0) .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; return null;
} }
@ -594,18 +621,7 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = state.BookID, BookID = state.BookID,
ProjectName = state.ProjectName, ProjectName = state.ProjectName,
BookTitle = state.BookDisplayTitle, BookTitle = state.BookDisplayTitle,
Items = committedRuns Items = batchItems
.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 characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport) var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)

View File

@ -246,16 +246,45 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
character = observation.Name, character = observation.Name,
identityKey = observation.CharacterKey, identityKey = observation.CharacterKey,
canonicalIdentity = observation.CharacterKey, canonicalIdentity = observation.CharacterKey,
rawExtractedEvidence = observation.IdentityEvidence, rawExtractedEvidence = CompactEvidence(observation.IdentityEvidence),
sceneEvidence = observation.TextEvidence, sceneEvidence = CompactEvidence(observation.TextEvidence),
relationshipEvidence = observation.RelationshipEvidence, relationshipEvidence = CompactEvidence(observation.RelationshipEvidence),
mergedEvidence = observation.TextEvidence.Concat(observation.RelationshipEvidence).ToArray(), mergedEvidence = CompactEvidence(observation.TextEvidence.Concat(observation.RelationshipEvidence)),
previousStoredEvidence = existing?.EvidenceJson, 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, finalMatcherEvidence = observation.Evidence,
evidenceSource = "Story Intelligence visualisation character state", evidenceSource = "Story Intelligence visualisation character state",
canonicalMergeReason = "Character state ID from canonical visualisation identity" canonicalMergeReason = "Character state ID from canonical visualisation identity"
}, JsonOptions); }, JsonOptions);
private static IReadOnlyList<string> CompactEvidence(IEnumerable<string> 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) private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
{ {
var age = NormaliseUnknown(demand.AgeBand, "YoungAdult"); var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");

View File

@ -25,6 +25,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
{ {
private const string FallbackRoot = "/images/story-intelligence/prototype"; 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() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@ -116,7 +119,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var current = PickCurrentScene(run, completed); var current = PickCurrentScene(run, completed);
var storyMemory = new StoryMemory(); 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) if (scenes.Count == 0)
{ {
scenes.Add(BuildEmptyScene(run)); scenes.Add(BuildEmptyScene(run));
@ -144,7 +148,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model); await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
ApplyIllustrationDiagnostics(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; return model;
} }
@ -209,6 +214,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var scenes = new List<StoryIntelligenceExperienceSceneState>(); var scenes = new List<StoryIntelligenceExperienceSceneState>();
var nextSceneNumber = 1; var nextSceneNumber = 1;
var storyMemory = new StoryMemory(); var storyMemory = new StoryMemory();
var allCompleted = runSnapshots.SelectMany(snapshot => snapshot.Completed).ToList();
var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, SceneWindowFollowing);
foreach (var snapshot in runSnapshots) foreach (var snapshot in runSnapshots)
{ {
var runScenes = BuildSceneStates( var runScenes = BuildSceneStates(
@ -227,7 +234,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
overallRemaining: overallRemaining, overallRemaining: overallRemaining,
continuousSceneNumbering: true, continuousSceneNumbering: true,
overallTerminal: isTerminal, overallTerminal: isTerminal,
storyMemory: storyMemory) storyMemory: storyMemory,
includedSceneResultIds: visibleSceneIds)
.ToList(); .ToList();
scenes.AddRange(runScenes); scenes.AddRange(runScenes);
nextSceneNumber += snapshot.Completed.Count; nextSceneNumber += snapshot.Completed.Count;
@ -272,7 +280,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model); await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
ApplyIllustrationDiagnostics(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; return model;
} }
@ -292,7 +301,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
string? overallRemaining = null, string? overallRemaining = null,
bool continuousSceneNumbering = false, bool continuousSceneNumbering = false,
bool overallTerminal = false, bool overallTerminal = false,
StoryMemory? storyMemory = null) StoryMemory? storyMemory = null,
IReadOnlySet<int>? includedSceneResultIds = null)
{ {
storyMemory ??= new StoryMemory(); storyMemory ??= new StoryMemory();
var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count); var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count);
@ -304,6 +314,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
storyMemory.Update(scene, item); storyMemory.Update(scene, item);
var sourceSceneNumber = SceneNumber(item); var sourceSceneNumber = SceneNumber(item);
var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber; 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; var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID;
yield return new StoryIntelligenceExperienceSceneState 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<int> VisibleSceneResultIds(
IReadOnlyList<ParsedSceneResult> completed,
ParsedSceneResult? current,
int previousCount = SceneWindowPrevious,
int followingCount = SceneWindowFollowing)
{
if (completed.Count == 0)
{
return new HashSet<int>();
}
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) private static bool IsTerminal(string status)
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled; => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;

View File

@ -148,15 +148,15 @@ public sealed class StoryIntelligenceExperienceImageResolution
public bool FallbackUsed { get; init; } = true; public bool FallbackUsed { get; init; } = true;
public decimal? AssignmentConfidence { get; init; } public decimal? AssignmentConfidence { get; init; }
public int? MatchingScore { get; init; } public int? MatchingScore { get; init; }
public string? RejectedCandidates { get; init; } public string? RejectedCandidates { get; set; }
public string? ReasonChosen { get; init; } public string? ReasonChosen { get; set; }
public string? ReasonReassigned { get; init; } public string? ReasonReassigned { get; set; }
public string? PreviousIllustration { get; init; } public string? PreviousIllustration { get; set; }
public string? PreviousEvidence { get; init; } public string? PreviousEvidence { get; set; }
public string? CurrentEvidence { get; init; } public string? CurrentEvidence { get; set; }
public string? DemandKey { get; init; } public string? DemandKey { get; set; }
public string? EvidenceWarnings { get; init; } public string? EvidenceWarnings { get; set; }
public string? DemandStatus { get; init; } public string? DemandStatus { get; set; }
public string? ResolvedPresentation { get; init; } public string? ResolvedPresentation { get; init; }
public string? ResolvedAgeBand { get; init; } public string? ResolvedAgeBand { get; init; }
public decimal? RelationshipDerivedConfidence { get; init; } public decimal? RelationshipDerivedConfidence { get; init; }

View File

@ -18,6 +18,21 @@
"error" => "No live snapshot", "error" => "No live snapshot",
_ => "Choose a session" _ => "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;
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark" data-bs-theme="dark"> <html lang="en" data-theme="dark" data-bs-theme="dark">
@ -28,7 +43,7 @@
<link rel="stylesheet" href="~/css/story-intelligence-experience-prototype.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/story-intelligence-experience-prototype.css" asp-append-version="true" />
</head> </head>
<body> <body>
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script> <script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(bootModel, jsonOptions))</script>
<main class="story-exp" <main class="story-exp"
data-story-experience data-story-experience
@ -37,8 +52,8 @@
data-run-id="@Model.RunId" data-run-id="@Model.RunId"
data-import-session-id="@Model.ImportSessionId" data-import-session-id="@Model.ImportSessionId"
data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)" data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)"
data-change-token="@Model.ChangeToken" data-change-token="@(Model.Mode == "real" ? string.Empty : Model.ChangeToken)"
data-terminal="@Model.IsTerminal.ToString().ToLowerInvariant()"> data-terminal="@((Model.Mode != "real" && Model.IsTerminal).ToString().ToLowerInvariant())">
<header class="story-exp-header" aria-label="Story Intelligence status"> <header class="story-exp-header" aria-label="Story Intelligence status">
<div class="story-exp-brand"> <div class="story-exp-brand">
<strong>PlotDirector</strong> <strong>PlotDirector</strong>

View File

@ -77,8 +77,9 @@
}; };
if (!scenes.length) { if (!scenes.length) {
state.changeToken = "";
state.currentSnapshot = { ...model, scenes: [] };
updateDiagnostics(); updateDiagnostics();
return;
} }
function loadSceneState(index) { function loadSceneState(index) {
@ -126,6 +127,12 @@
scenes = model.scenes || []; scenes = model.scenes || [];
state.changeToken = model.changeToken || state.changeToken; state.changeToken = model.changeToken || state.changeToken;
state.terminal = !!model.isTerminal; 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)); state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1));
if (model.projectName) { if (model.projectName) {
text(dom.projectName, model.projectName); text(dom.projectName, model.projectName);
@ -1065,6 +1072,7 @@
} }
function startTimer() { function startTimer() {
if (!scenes.length) return;
stopTimer(); stopTimer();
if (!state.playing || !simulationMode) return; if (!state.playing || !simulationMode) return;
state.timer = window.setInterval(() => { state.timer = window.setInterval(() => {
@ -1384,6 +1392,7 @@
}); });
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
if (!scenes.length) return;
const current = loadSceneState(state.index); const current = loadSceneState(state.index);
const layout = sceneLayout(current); const layout = sceneLayout(current);
reconcileCharacters(current, current, layout); reconcileCharacters(current, current, layout);
@ -1394,6 +1403,7 @@
if (window.ResizeObserver && dom.visualStage) { if (window.ResizeObserver && dom.visualStage) {
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
if (!scenes.length) return;
const current = loadSceneState(state.index); const current = loadSceneState(state.index);
const layout = sceneLayout(current); const layout = sceneLayout(current);
reconcileCharacters(current, current, layout); reconcileCharacters(current, current, layout);
@ -1403,8 +1413,12 @@
observer.observe(dom.visualStage); observer.observe(dom.visualStage);
} }
if (scenes.length) {
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0); transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
startTimer(); startTimer();
} else {
updateDiagnostics();
}
startPolling(); startPolling();
if (realMode && !state.terminal) pollSnapshot(); if (realMode && !state.terminal) pollSnapshot();
})(); })();