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.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<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()
=> new()
{

View File

@ -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<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
{

View File

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

View File

@ -47,8 +47,12 @@ public class Program
builder.Configuration.GetSection("StoryIntelligence").Bind(options);
});
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()
.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");
}
}

View File

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

View File

@ -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<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)
{
var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");

View File

@ -25,6 +25,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ILogger<StoryIntelligenceVisualisationSnapshotService> 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<StoryIntelligenceExperienceSceneState>();
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<int>? 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<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)
=> 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 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; }

View File

@ -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;
}
<!DOCTYPE html>
<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" />
</head>
<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"
data-story-experience
@ -37,8 +52,8 @@
data-run-id="@Model.RunId"
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-change-token="@Model.ChangeToken"
data-terminal="@Model.IsTerminal.ToString().ToLowerInvariant()">
data-change-token="@(Model.Mode == "real" ? string.Empty : Model.ChangeToken)"
data-terminal="@((Model.Mode != "real" && Model.IsTerminal).ToString().ToLowerInvariant())">
<header class="story-exp-header" aria-label="Story Intelligence status">
<div class="story-exp-brand">
<strong>PlotDirector</strong>

View File

@ -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);
}
if (scenes.length) {
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
startTimer();
} else {
updateDiagnostics();
}
startPolling();
if (realMode && !state.terminal) pollSnapshot();
})();