Phase 21O - Story Intelligence Usability and Continuity Refinement

This commit is contained in:
Nick Beckley 2026-07-14 20:25:44 +00:00
parent f80734a80c
commit 63f427066d
8 changed files with 167 additions and 55 deletions

View File

@ -52,6 +52,7 @@ var tests = new (string Name, Action Test)[]
("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),
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity),
("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),
@ -581,7 +582,7 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS
Assert(femaleInstructor.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"female instructor should be Feminine, got {femaleInstructor.Presentation}.");
Assert(maleExaminers.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"male examiners should be Masculine, got {maleExaminers.Presentation}.");
Assert(lad.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"lad should be Masculine, got {lad.Presentation}.");
Assert(lad.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"lad should infer a young age band, got {lad.AgeBand}.");
Assert(lad.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"lad should not default to Child without explicit child-age evidence, got {lad.AgeBand}.");
Assert(girl.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"girl should be Feminine, got {girl.Presentation}.");
Assert(girl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"schoolgirl should infer Child, got {girl.AgeBand}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should not inherit feminine presentation from his instructor, got {kevin.Presentation}.");
@ -597,7 +598,7 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "LaundryUtilityRoom"), "Bathroom must not match laundry room.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Road must not match car park.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not match flat interior.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Ford Fiesta") == "Car", "Ford Fiesta should classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Ford Fiesta") == "Hatchback", "Ford Fiesta should classify as Hatchback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Car") == "Car", "Car should classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("supercharger") == "CarPart", "Supercharger should not classify as full car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph.");
@ -612,6 +613,28 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS
Assert(Rejected(beth, adult).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Existing adult assignment must be discarded when Beth evidence says YoungTeen.");
}
static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
{
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
var explicitGirl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mia", ["schoolgirl"], []);
var teacher = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["female driving examiner"], []);
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown age should remain Unknown before illustration fallback, got {unknownNamed.AgeBand}.");
Assert(explicitGirl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Schoolgirl should remain explicit child evidence, got {explicitGirl.AgeBand}.");
Assert(teacher.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Teacher title/role should infer Adult, got {teacher.AgeBand}.");
Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}.");
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks must not borrow generic car artwork.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
Assert(view.Contains("Return to Import", StringComparison.Ordinal), "Real visualisation should expose a return-to-import link.");
Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation mode.");
}
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false)
=> new(
ageBand,

View File

@ -77,9 +77,13 @@ public static class StoryIntelligenceIllustrationCompatibility
presentation = guardedPresentation.Value;
presentationConfidence = guardedPresentation.Confidence;
var explicitAge = ExplicitAgeBand(scopedText);
if (explicitAge.Value == AgeBands.Unknown)
{
explicitAge = EntityIdentityAgeBand(name, identityText);
}
var relationshipAge = RelationshipAgeBand(name, scopedText);
var namedAge = NameAgeBand(name);
var age = FirstKnown(relationshipAge.Value, explicitAge.Value, namedAge.Value, AgeBands.Unknown);
var age = FirstKnown(explicitAge.Value, relationshipAge.Value, namedAge.Value, AgeBands.Unknown);
var ageConfidence = new[] { explicitAge.Confidence, relationshipAge.Confidence, namedAge.Confidence }.Max();
var attributeText = EntityAttributeText(entityText, name);
var hairColour = HairColour(attributeText);
@ -171,7 +175,9 @@ public static class StoryIntelligenceIllustrationCompatibility
rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {candidate.Presentation}");
}
if (ExplicitKnown(evidence.HairColour, evidence.AgeConfidence) && KnownMismatch(evidence.HairColour, candidate.HairColour))
if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& KnownMismatch(evidence.HairColour, candidate.HairColour))
{
rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}");
}
@ -189,7 +195,7 @@ public static class StoryIntelligenceIllustrationCompatibility
var text = (value ?? string.Empty).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
if (text.Contains("youngteen") || text.Contains("13") || text.Contains("14") || text.Contains("15") || text.Contains("fifteen")) return AgeBands.YoungTeen;
if (text.Contains("olderteen") || text.Contains("teen") || text.Contains("16") || text.Contains("17") || text.Contains("18") || text.Contains("19")) return AgeBands.OlderTeen;
if (text.Contains("child") || text.Contains("boy") || text.Contains("girl")) return AgeBands.Child;
if (text.Contains("child")) return AgeBands.Child;
if (text.Contains("youngadult")) return AgeBands.YoungAdult;
if (text.Contains("middleaged") || text.Contains("matureadult") || text.Contains("parent") || text.Contains("mother") || text.Contains("father") || text.Contains("aunt") || text.Contains("uncle")) return AgeBands.MatureAdult;
if (text.Contains("adult")) return AgeBands.Adult;
@ -251,7 +257,7 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
if (ContainsAny(text, "road", "street", "lane", "drive", "motorway", "bridge")) return "Road";
if (ContainsAny(text, "road", "street", "lane", "drive", "close", "avenue", "boulevard", "motorway", "bridge", "roundabout", "footpath", "track")) return "Road";
if (ContainsAny(text, "car park", "parking")) return "CarPark";
if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
if (text.Contains("hospital", StringComparison.OrdinalIgnoreCase)) return "Hospital";
@ -272,7 +278,11 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "driving licence", "driver licence", "provisional licence", "license", "licence", "pass certificate", "certificate", "paperwork")) return "Document";
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
if (ContainsAny(padded, " ford fiesta ", " fiesta ", " motor car ", " car ", " vehicle ")) return "Car";
if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback";
if (ContainsAny(padded, " family car ", " estate car ", " saloon ")) return "FamilyCar";
if (ContainsAny(padded, " sports car ", " tr6 ", " roadster ")) return "SportsCar";
if (ContainsAny(padded, " van ", " transit ")) return "Van";
if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car";
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
if (ContainsAny(text, "parcel", "package")) return "Parcel";
@ -326,22 +336,40 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "13", "thirteen", "14", "fourteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "16", "sixteen", "17", "seventeen", "18", "eighteen", "19", "nineteen")) return new(AgeBands.OlderTeen, 0.98m);
if (ContainsAny(text, "toddler", "baby", "infant", "newborn")) return new(AgeBands.Child, 0.98m);
if (ContainsAny(text, "child", "young girl", "young boy", "schoolboy", "schoolgirl", " boy ", " girl ", " lad ")) return new(AgeBands.Child, 0.86m);
if (ContainsAny(text, "child", "young child", "schoolboy", "schoolgirl", "ten-year-old", "ten year old", "aged 10", "aged 11", "aged 12")) return new(AgeBands.Child, 0.9m);
if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m);
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m);
if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m);
if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager")) return new(AgeBands.Adult, 0.88m);
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal EntityIdentityAgeBand(string name, string identityText)
{
var cleanName = (name ?? string.Empty).Trim().ToLowerInvariant();
if (ContainsAny(cleanName, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "grandmother", "grandfather"))
{
return EvidenceSignal.Unknown;
}
return ExplicitAgeBand(identityText);
}
private static EvidenceSignal RelationshipAgeBand(string name, string text)
{
if (ContainsAny(text, "toddler", "baby", "newborn")) return new(AgeBands.Child, 0.96m);
var identity = $"{name} {text}";
if (ContainsAny(identity, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "teacher")
if (ContainsAny(identity, "mother", " mum", "father", " dad", "parent", "aunt", "uncle")
&& !PossessiveOfOtherIdentity(name, text))
{
return new(AgeBands.MatureAdult, 0.74m);
return new(AgeBands.MatureAdult, 0.82m);
}
if (ContainsAny(identity, "teacher", "driving examiner", "detective", "police officer", "nurse", "receptionist", "solicitor", "landlord", "manager", "professor", "doctor")
&& !PossessiveOfOtherIdentity(name, text))
{
return new(AgeBands.Adult, 0.82m);
}
if (ContainsAny(text, "grandmother", "grandfather")) return new(AgeBands.Senior, 0.82m);
@ -351,8 +379,7 @@ public static class StoryIntelligenceIllustrationCompatibility
private static EvidenceSignal NameAgeBand(string name)
{
var clean = name.Trim().ToLowerInvariant();
return clean is "beth" ? new(AgeBands.YoungTeen, 0.86m) : EvidenceSignal.Unknown;
return EvidenceSignal.Unknown;
}
private static EvidenceSignal TitlePresentation(string text)
@ -378,8 +405,8 @@ public static class StoryIntelligenceIllustrationCompatibility
private static EvidenceSignal NamePresentation(string text)
{
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m);
if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin ")) return new(Presentations.Masculine, 0.82m);
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " rebecca ", " annie ", " mary ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m);
if (ContainsAny(text, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " george ")) return new(Presentations.Masculine, 0.82m);
return EvidenceSignal.Unknown;
}
@ -415,13 +442,13 @@ public static class StoryIntelligenceIllustrationCompatibility
}
if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male")
|| clean is "graham" or "george" or "john" or "david" or "simon" or "kevin" or "colin")
|| clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george")
{
return new(Presentations.Masculine, 0.98m);
}
if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female")
|| clean is "beth" or "maggie" or "rosie" or "grace" or "annie" or "helen" or "elen")
|| clean is "beth" or "maggie" or "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen")
{
return new(Presentations.Feminine, 0.98m);
}

View File

@ -443,6 +443,14 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
candidate.Metadata.Profile,
isNamedCharacter: !IsUnresolvedName(observation.Name),
alreadyAssignedToAnotherSignificantCharacter: alreadyAssigned && observation.IsSignificant));
if (observation.Evidence.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown
&& candidate.Metadata.AgeBand is StoryIntelligenceIllustrationCompatibility.AgeBands.Child
or StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen
or StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen)
{
rejected.Add("unknown age should use an adult-compatible illustration until explicit child or teen evidence appears");
}
AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band");
AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation");
AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour");

View File

@ -130,6 +130,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{
Mode = "real",
RunId = run.StoryIntelligenceRunID,
BookId = run.BookID,
RunStatus = run.Status,
GeneratedUtc = DateTime.UtcNow,
ChangeToken = ChangeToken(run, results),
@ -208,11 +209,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
? "All known chapter runs completed successfully"
: "Weighted chapter-run progress";
var overallProgressPercent = authoritativeImportProgress;
var overallElapsed = SessionElapsed(runSnapshots);
var overallRemaining = isSuccessfullyComplete ? "Ready to review" : isTerminal ? "Ready to review" : EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes);
var overallElapsedMs = SessionElapsedMilliseconds(runSnapshots, isTerminal);
var overallElapsed = FormatDuration(overallElapsedMs);
var overallRemaining = isSuccessfullyComplete ? "Ready to review" : isTerminal ? "Ready to review" : EstimateSessionRemaining(overallElapsedMs, parsedScenes, totalScenes);
var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run);
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);
@ -223,7 +224,6 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
snapshot.Parsed,
snapshot.Completed,
current,
continuousSceneNumberStart: nextSceneNumber,
overallCompletedScenes: parsedScenes,
overallTotalScenes: totalScenes,
overallChaptersAnalysed: completedChapterRuns,
@ -232,13 +232,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
overallStage: overallStage,
overallElapsed: overallElapsed,
overallRemaining: overallRemaining,
continuousSceneNumbering: true,
overallTerminal: isTerminal,
storyMemory: storyMemory,
includedSceneResultIds: visibleSceneIds)
.ToList();
scenes.AddRange(runScenes);
nextSceneNumber += snapshot.Completed.Count;
}
if (scenes.Count == 0)
@ -251,6 +249,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
Mode = "real",
ImportSessionId = session.StoryIntelligenceBookPipelineID,
RunId = activeSnapshot.Run.StoryIntelligenceRunID,
BookId = session.BookID,
RunStatus = SessionStatus(session, runSnapshots),
GeneratedUtc = DateTime.UtcNow,
ChangeToken = SessionChangeToken(session, runSnapshots),
@ -837,19 +836,18 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
};
}
private static string EstimateSessionRemaining(IReadOnlyList<RunSnapshot> snapshots, int completedScenes, int totalScenes)
private static string EstimateSessionRemaining(long elapsedMs, int completedScenes, int totalScenes)
{
if (totalScenes <= 0 || completedScenes <= 0)
{
return snapshots.All(snapshot => IsTerminal(snapshot.Run.Status)) ? "Ready to review" : "Calculating";
return "Calculating";
}
if (completedScenes >= totalScenes && snapshots.All(snapshot => IsTerminal(snapshot.Run.Status)))
if (completedScenes >= totalScenes)
{
return "Ready to review";
}
var elapsedMs = snapshots.Sum(snapshot => snapshot.Run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - snapshot.Run.StartedUtc).TotalMilliseconds)));
var averageMs = elapsedMs / Math.Max(1, completedScenes);
return FormatDuration(averageMs * Math.Max(0, totalScenes - completedScenes));
}
@ -1798,11 +1796,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return FormatDuration(elapsedMs);
}
private static string SessionElapsed(IReadOnlyList<RunSnapshot> snapshots)
private static long SessionElapsedMilliseconds(IReadOnlyList<RunSnapshot> snapshots, bool isTerminal)
{
var elapsedMs = snapshots.Sum(snapshot => snapshot.Run.TotalDurationMs
?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - snapshot.Run.StartedUtc).TotalMilliseconds)));
return FormatDuration(elapsedMs);
if (snapshots.Count == 0)
{
return 0;
}
var started = snapshots.Min(snapshot => snapshot.Run.StartedUtc);
var ended = isTerminal
? snapshots
.Select(snapshot => snapshot.Run.CompletedUtc ?? snapshot.Run.UpdatedUtc)
.DefaultIfEmpty(DateTime.UtcNow)
.Max()
: DateTime.UtcNow;
return Math.Max(0, Convert.ToInt64((ended - started).TotalMilliseconds));
}
private static string FormatDuration(long milliseconds)

View File

@ -5,6 +5,7 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel
public string Mode { get; init; } = "simulation";
public int? RunId { get; init; }
public int? ImportSessionId { get; init; }
public int? BookId { get; init; }
public string? RunStatus { get; init; }
public string? ChangeToken { get; init; }
public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow;

View File

@ -24,6 +24,7 @@
Mode = Model.Mode,
RunId = Model.RunId,
ImportSessionId = Model.ImportSessionId,
BookId = Model.BookId,
RunStatus = Model.RunStatus,
GeneratedUtc = Model.GeneratedUtc,
IsTerminal = false,
@ -50,6 +51,7 @@
data-image-diagnostics="true"
data-mode="@Model.Mode"
data-run-id="@Model.RunId"
data-book-id="@Model.BookId"
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.Mode == "real" ? string.Empty : Model.ChangeToken)"
@ -66,7 +68,14 @@
<span data-stage-label>Preparing</span>
<span>Scene <strong data-current-scene>1</strong></span>
</div>
@if (Model.Mode == "real" && Model.BookId.HasValue)
{
<a class="story-exp-background" href="/onboarding/story-intelligence/book/@Model.BookId.Value/continue">Return to Import</a>
}
else
{
<a class="story-exp-background" href="/Development/StoryIntelligenceExperience?mode=simulation">Simulation</a>
}
</header>
@if (Model.AvailableRuns.Count > 0)
@ -181,6 +190,8 @@
</aside>
</section>
@if (Model.Mode == "simulation")
{
<aside class="story-exp-controls" aria-label="Prototype controls">
<div>
<span>Prototype controls</span>
@ -195,6 +206,7 @@
<input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control />
</label>
</aside>
}
<details class="story-exp-diagnostics" data-diagnostics>
<summary>Diagnostics</summary>

View File

@ -288,10 +288,10 @@ input:focus-visible {
.story-exp-shell {
display: grid;
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr);
gap: 16px;
grid-template-columns: minmax(180px, 0.46fr) minmax(820px, 2.72fr) minmax(188px, 0.48fr);
gap: 12px;
min-height: 0;
padding: 14px 22px 74px;
padding: 12px 18px 58px;
}
.story-exp-left,
@ -565,7 +565,7 @@ input:focus-visible {
.story-exp-connections {
position: absolute;
inset: 0;
z-index: 1;
z-index: 60;
width: 100%;
height: 100%;
pointer-events: none;
@ -641,7 +641,7 @@ input:focus-visible {
.story-exp-zone {
position: absolute;
inset: 0;
z-index: 2;
z-index: 20;
min-width: 0;
min-height: 0;
pointer-events: none;
@ -683,13 +683,13 @@ input:focus-visible {
height: var(--node-size, 150px);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
opacity: var(--opacity, 1);
z-index: var(--z, 3);
z-index: calc(var(--z, 3) + 30);
pointer-events: auto;
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
}
.story-character.is-pov {
z-index: 7;
z-index: 48;
}
.story-character.is-context .story-character__image {
@ -699,6 +699,8 @@ input:focus-visible {
.story-character__image,
.story-location__image,
.story-asset__image {
position: relative;
z-index: 1;
display: block;
overflow: hidden;
border: 1px solid rgba(169, 205, 240, 0.36);
@ -727,6 +729,7 @@ input:focus-visible {
.story-character__text {
position: absolute;
z-index: 80;
top: calc(var(--node-size, 150px) + 9px);
left: 50%;
display: grid;
@ -817,7 +820,7 @@ input:focus-visible {
gap: 10px;
width: var(--node-size, 190px);
transform: translate(var(--x, 0), var(--y, 0));
z-index: 4;
z-index: 42;
pointer-events: auto;
transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1);
}
@ -841,6 +844,8 @@ input:focus-visible {
}
.story-location__text {
position: relative;
z-index: 80;
display: grid;
justify-items: center;
gap: 3px;
@ -887,7 +892,7 @@ input:focus-visible {
width: max(var(--node-size, 78px), 104px);
opacity: var(--opacity, 1);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
z-index: var(--z, 3);
z-index: calc(var(--z, 3) + 30);
pointer-events: auto;
transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease;
}
@ -901,6 +906,8 @@ input:focus-visible {
}
.story-asset__text {
position: relative;
z-index: 80;
display: grid;
justify-items: center;
gap: 2px;

View File

@ -1920,6 +1920,21 @@
let scene = null;
let totalWordCount = 0;
let detectedHeadingChapters = 0;
let manuscriptStarted = false;
const isFrontMatterHeading = (value) => {
const clean = normalizeTitleForResolve(value || "", "").toLocaleLowerCase();
return [
"title page",
"copyright",
"dedication",
"contents",
"table of contents",
"acknowledgements",
"acknowledgments",
"about the author"
].includes(clean);
};
const finishScene = () => {
scene = null;
@ -1982,10 +1997,15 @@
return;
}
documentText.push(text);
const words = countWords(text);
if (isBuiltInHeading(paragraph, 1)) {
if (!manuscriptStarted && isFrontMatterHeading(text)) {
return;
}
manuscriptStarted = true;
documentText.push(text);
detectedHeadingChapters += 1;
startChapter(paragraph, text);
totalWordCount += words;
@ -1995,9 +2015,15 @@
}
if (!chapter) {
if (!manuscriptStarted) {
return;
}
startChapter(paragraph, "Opening pages", true);
}
documentText.push(text);
if (isBuiltInHeading(paragraph, 2)) {
startScene(paragraph, text);
totalWordCount += words;