Phase 21H.1 - Fix Active Import Session Selection

This commit is contained in:
Nick Beckley 2026-07-12 19:00:38 +00:00
parent 8985efafbc
commit 2b7a4b599d
7 changed files with 268 additions and 30 deletions

View File

@ -21,43 +21,81 @@ public sealed class DevelopmentController(
return NotFound();
}
if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && importSessionId.HasValue && currentUser.UserId is int userId)
var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase);
if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId)
{
var realModel = await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId);
if (realModel is null)
{
return NotFound();
return View(ErrorModel(
requestedMode,
importSessionId,
$"Import Session {importSessionId.Value:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet."));
}
return View(realModel);
}
if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && runId.HasValue && currentUser.UserId is int legacyUserId)
if (!simulationRequested && runId.HasValue && currentUser.UserId is int legacyUserId)
{
var realModel = await snapshots.BuildSnapshotAsync(runId.Value, legacyUserId);
if (realModel is null)
{
return NotFound();
return View(ErrorModel(
requestedMode,
null,
$"Run {runId.Value:N0} could not be found for the current user. Live visualisation URLs should use importSessionId for book imports."));
}
return View(realModel);
}
if (simulationRequested)
{
var model = StoryIntelligenceExperiencePrototypeData.Build();
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && currentUser.UserId is int selectorUserId)
{
model = new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "selector",
AvailableRuns = await snapshots.ListImportSessionOptionsAsync(selectorUserId),
ProjectName = model.ProjectName,
BookTitle = model.BookTitle,
Scenes = model.Scenes,
SnapshotMessage = "Choose a Story Intelligence import session, or open simulation mode."
};
}
return View(model);
}
if (currentUser.UserId is int selectorUserId)
{
var sessions = await snapshots.ListImportSessionOptionsAsync(selectorUserId);
return View(new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "selector",
AvailableRuns = sessions,
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = sessions.Count == 0
? "No persisted Story Intelligence import sessions were found for the current user. Start a real import, then refresh this page."
: "Choose a Story Intelligence import session, or explicitly open simulation mode.",
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real"
}
});
}
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
}
private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message)
=> new()
{
Mode = "error",
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = message,
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real",
Warning = message
}
};
}

View File

@ -9,6 +9,12 @@ public static class StoryIntelligenceExperiencePrototypeData
public static StoryIntelligenceExperiencePrototypeViewModel Build()
=> new()
{
Mode = "simulation",
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = "simulation",
SnapshotSource = "Simulation"
},
ProjectName = "Alpha Flame",
BookTitle = "The Doweries Manuscript",
Scenes =

View File

@ -19,6 +19,7 @@ public interface IStoryIntelligenceVisualisationSnapshotService
public sealed class StoryIntelligenceVisualisationSnapshotService(
IStoryIntelligenceResultRepository repository,
IStoryIntelligencePipelineRepository pipelines,
IStoryIntelligenceSourceRepository sources,
IIllustrationLibraryService illustrationLibrary,
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
{
@ -48,6 +49,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
public async Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId)
{
await EnsureActiveImportSessionsAsync(userId);
var sessions = await pipelines.ListForUserAsync(userId);
return sessions
.OrderByDescending(session => session.UpdatedUtc)
@ -63,6 +65,34 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
.ToList();
}
private async Task EnsureActiveImportSessionsAsync(int userId)
{
var runs = await repository.ListRunsAsync();
var activeBookRuns = runs
.Where(run => run.UserID == userId
&& run.ProjectID.HasValue
&& run.BookID.HasValue
&& !IsTerminal(run.Status))
.GroupBy(run => new { ProjectID = run.ProjectID!.Value, BookID = run.BookID!.Value })
.Select(group => group.OrderByDescending(run => run.CreatedUtc).First())
.ToList();
foreach (var run in activeBookRuns)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = run.ProjectID!.Value,
BookID = run.BookID!.Value,
CurrentStage = StoryIntelligencePipelineStages.Chapters,
LastCompletedStage = null,
CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.InProgress,
CompletedUtc = null,
LastRunID = run.StoryIntelligenceRunID
});
}
}
public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId)
{
var stopwatch = Stopwatch.StartNew();
@ -150,9 +180,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var totalScenes = Math.Max(
runSnapshots.Sum(snapshot => Math.Max(snapshot.Run.TotalDetectedScenes ?? 0, snapshot.Results.Count)),
parsedScenes);
var sourceChapterCount = (await sources.ListChaptersAsync(session.BookID)).Count;
var bookChapterTotal = Math.Max(1, Math.Max(sourceChapterCount, runSnapshots.Count));
var overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isTerminal: false);
var completedChapterRuns = runSnapshots.Count(snapshot => IsTerminal(snapshot.Run.Status));
var isTerminal = runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status))
&& string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase);
overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isTerminal);
var scenes = new List<StoryIntelligenceExperienceSceneState>();
var nextSceneNumber = 1;
foreach (var snapshot in runSnapshots)
@ -166,7 +200,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
overallCompletedScenes: parsedScenes,
overallTotalScenes: totalScenes,
overallChaptersAnalysed: completedChapterRuns,
overallChapterTotal: runs.Count,
overallChapterTotal: bookChapterTotal,
overallProgressPercent: overallProgressPercent,
overallStage: OverallStage(session, activeSnapshot.Run),
overallRemaining: EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes),
continuousSceneNumbering: true,
@ -191,7 +226,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ChangeToken = SessionChangeToken(session, runSnapshots),
IsTerminal = isTerminal,
SnapshotMessage = SessionSnapshotMessage(session, runSnapshots),
Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults),
Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults, overallProgressPercent),
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
Scenes = scenes
@ -212,6 +247,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
int? overallTotalScenes = null,
int? overallChaptersAnalysed = null,
int? overallChapterTotal = null,
int? overallProgressPercent = null,
string? overallStage = null,
string? overallRemaining = null,
bool continuousSceneNumbering = false,
@ -235,9 +271,10 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
Stage = overallStage ?? StageDisplay(run),
TimelineLabel = TimelineLabel(scene, item.Result),
TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42),
ProgressPercent = overallTotalScenes.HasValue
ProgressPercent = overallProgressPercent
?? (overallTotalScenes.HasValue
? OverallProgress(overallCompletedScenes ?? completedScenes, overallTotalScenes.Value, overallTerminal)
: Progress(run, completedScenes, totalScenes),
: Progress(run, completedScenes, totalScenes)),
ChaptersAnalysed = overallChaptersAnalysed ?? ChapterCount(completed),
ChapterTotal = Math.Max(1, overallChapterTotal ?? run.SourceChapterCount ?? ChapterCount(completed)),
ScenesAnalysed = overallCompletedScenes ?? completedScenes,
@ -617,18 +654,39 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
: "All known chapter runs are complete. Awaiting the next book import stage.";
}
private static string CurrentSceneText(RunSnapshot snapshot)
{
if (snapshot.Run.TotalDetectedScenes.HasValue)
{
var completed = Math.Clamp(snapshot.Run.CompletedScenes ?? snapshot.Completed.Count, 0, snapshot.Run.TotalDetectedScenes.Value);
return $"Scene {completed:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}";
}
return snapshot.Current is null ? "Waiting" : $"Scene {SceneSortNumber(snapshot.Current):0.##}";
}
private static StoryIntelligenceExperienceSnapshotDiagnostics BuildSessionDiagnostics(
StoryIntelligenceBookPipelineState session,
IReadOnlyList<RunSnapshot> snapshots,
RunSnapshot activeSnapshot,
ParsedSceneResult? current,
int parsedScenes,
int totalPersistedResults)
int totalPersistedResults,
int overallProgressPercent)
{
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
return new()
{
RequestedMode = "real",
RequestedImportSessionId = session.StoryIntelligenceBookPipelineID,
ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID,
ActiveChapterRunId = activeSnapshot.Run.StoryIntelligenceRunID,
SessionStatus = session.Status,
OverallBookProgress = overallProgressPercent,
CurrentChapter = activeSnapshot.Run.ChapterNumber.HasValue ? $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}" : "Chapter unknown",
CurrentScene = CurrentSceneText(activeSnapshot),
SnapshotSource = "Real",
RunStatus = SessionStatus(session, snapshots),
PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
TotalPersistedSceneResultCount = totalPersistedResults,
@ -658,6 +716,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return new()
{
RequestedMode = "real",
ActiveChapterRunId = run.StoryIntelligenceRunID,
SessionStatus = run.Status,
OverallBookProgress = Progress(run, run.CompletedScenes ?? completed.Count, Math.Max(run.TotalDetectedScenes ?? 0, results.Count)),
CurrentChapter = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter unknown",
CurrentScene = run.TotalDetectedScenes.HasValue
? $"Scene {Math.Clamp(run.CompletedScenes ?? completed.Count, 0, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}"
: (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"),
SnapshotSource = "Real",
RunStatus = run.Status,
PersistedCompletedSceneCount = run.CompletedScenes ?? 0,
TotalPersistedSceneResultCount = results.Count,
@ -732,6 +799,43 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return 0;
}
private static int BookProgress(IReadOnlyList<RunSnapshot> snapshots, int chapterTotal, bool isTerminal)
{
if (isTerminal)
{
return 100;
}
if (chapterTotal <= 0)
{
return 0;
}
var chapterProgress = snapshots.Sum(ChapterProgress);
return Math.Clamp((int)Math.Floor(chapterProgress * 100m / chapterTotal), 0, 99);
}
private static decimal ChapterProgress(RunSnapshot snapshot)
{
if (IsTerminal(snapshot.Run.Status))
{
return 1m;
}
if (snapshot.Run.Status == StoryIntelligenceRunStatuses.Pending)
{
return 0m;
}
if ((snapshot.Run.TotalDetectedScenes ?? 0) > 0)
{
var sceneProgress = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) / Math.Max(1m, snapshot.Run.TotalDetectedScenes ?? 1), 0m, 1m);
return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m);
}
return 0.15m;
}
private static string EstimateRemaining(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes)
{
if (IsTerminal(run.Status))

View File

@ -19,6 +19,15 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel
public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
{
public string RequestedMode { get; init; } = string.Empty;
public int? RequestedImportSessionId { get; init; }
public int? ResolvedImportSessionId { get; init; }
public int? ActiveChapterRunId { get; init; }
public string SessionStatus { get; init; } = string.Empty;
public int OverallBookProgress { get; init; }
public string CurrentChapter { get; init; } = string.Empty;
public string CurrentScene { get; init; } = string.Empty;
public string SnapshotSource { get; init; } = string.Empty;
public string RunStatus { get; init; } = string.Empty;
public int PersistedCompletedSceneCount { get; init; }
public int TotalPersistedSceneResultCount { get; init; }

View File

@ -3,6 +3,21 @@
@{
Layout = null;
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var statusLabel = Model.Mode switch
{
"simulation" => "Simulation",
"real" when Model.IsTerminal => "Analysis Complete",
"real" => "Live Analysis",
"error" => "Import Session Error",
_ => "Select Import Session"
};
var sourceLabel = Model.Mode switch
{
"simulation" => "Simulation only",
"real" => "Real snapshot",
"error" => "No live snapshot",
_ => "Choose a session"
};
}
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-bs-theme="dark">
@ -31,6 +46,7 @@
</div>
<ol class="story-exp-pipeline" data-analysis-stages aria-label="Analysis stages"></ol>
<div class="story-exp-context">
<span>@statusLabel</span>
<span>@Model.ProjectName</span>
<span>@Model.BookTitle</span>
<span data-stage-label>Preparing</span>
@ -50,6 +66,13 @@
</nav>
}
@if (!string.IsNullOrWhiteSpace(Model.SnapshotMessage))
{
<aside class="story-exp-status-message" role="status">
@Model.SnapshotMessage
</aside>
}
<section class="story-exp-shell" aria-label="Story Intelligence visual prototype">
<aside class="story-exp-left" aria-label="Analysis progress">
<div class="story-exp-progress">
@ -147,7 +170,7 @@
<aside class="story-exp-controls" aria-label="Prototype controls">
<div>
<span>Prototype controls</span>
<small>@(Model.Mode == "real" ? "Real snapshot" : "Simulation only")</small>
<small>@sourceLabel</small>
</div>
<button type="button" data-control="restart">Restart</button>
<button type="button" data-control="previous">Previous</button>
@ -162,6 +185,42 @@
<details class="story-exp-diagnostics" data-diagnostics>
<summary>Diagnostics</summary>
<dl>
<div>
<dt>Requested mode</dt>
<dd data-diagnostic-key="requestedMode">@Model.Diagnostics.RequestedMode</dd>
</div>
<div>
<dt>Requested ImportSessionID</dt>
<dd data-diagnostic-key="requestedImportSessionId">@Model.Diagnostics.RequestedImportSessionId</dd>
</div>
<div>
<dt>Resolved ImportSessionID</dt>
<dd data-diagnostic-key="resolvedImportSessionId">@Model.Diagnostics.ResolvedImportSessionId</dd>
</div>
<div>
<dt>Active chapter RunID</dt>
<dd data-diagnostic-key="activeChapterRunId">@Model.Diagnostics.ActiveChapterRunId</dd>
</div>
<div>
<dt>Session status</dt>
<dd data-diagnostic-key="sessionStatus">@Model.Diagnostics.SessionStatus</dd>
</div>
<div>
<dt>Overall book progress</dt>
<dd data-diagnostic-key="overallBookProgress">@Model.Diagnostics.OverallBookProgress</dd>
</div>
<div>
<dt>Current chapter</dt>
<dd data-diagnostic-key="currentChapter">@Model.Diagnostics.CurrentChapter</dd>
</div>
<div>
<dt>Current scene</dt>
<dd data-diagnostic-key="currentScene">@Model.Diagnostics.CurrentScene</dd>
</div>
<div>
<dt>Snapshot source</dt>
<dd data-diagnostic-key="snapshotSource">@Model.Diagnostics.SnapshotSource</dd>
</div>
<div>
<dt>Current Snapshot Version</dt>
<dd data-diagnostics-current>0</dd>

View File

@ -327,6 +327,23 @@ input:focus-visible {
color: var(--story-text);
}
.story-exp-status-message {
position: fixed;
left: 50%;
top: 86px;
z-index: 19;
width: min(720px, calc(100vw - 48px));
border: 1px solid rgba(215, 168, 93, 0.32);
border-radius: 12px;
padding: 10px 14px;
background: rgba(6, 14, 26, 0.9);
box-shadow: var(--story-shadow);
color: var(--story-text);
font-size: 0.84rem;
line-height: 1.45;
transform: translateX(-50%);
}
.story-exp-shell {
display: grid;
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr);

View File

@ -5,10 +5,10 @@
let model = JSON.parse(dataNode.textContent || "{}");
let scenes = model.scenes || [];
if (!scenes.length) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const realMode = root.dataset.mode === "real";
const simulationMode = root.dataset.mode === "simulation";
const snapshotUrl = root.dataset.snapshotUrl || "";
const failedImageUrls = new Set();
const preloadedImageUrls = new Set();
@ -88,6 +88,11 @@
diagnosticValues: root.querySelectorAll("[data-diagnostic-key]")
};
if (!scenes.length) {
updateDiagnostics();
return;
}
function loadSceneState(index) {
return scenes[Math.max(0, Math.min(scenes.length - 1, index))];
}
@ -1070,7 +1075,7 @@
function startTimer() {
stopTimer();
if (!state.playing || realMode) return;
if (!state.playing || !simulationMode) return;
state.timer = window.setInterval(() => {
const next = state.index >= scenes.length - 1 ? 0 : state.index + 1;
transitionToNextState(next);
@ -1341,32 +1346,32 @@
}
root.querySelector("[data-control='restart']")?.addEventListener("click", () => {
if (realMode) return;
if (!simulationMode) return;
transitionToNextState(0);
startTimer();
});
root.querySelector("[data-control='previous']")?.addEventListener("click", () => {
if (realMode) return;
if (!simulationMode) return;
transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1);
startTimer();
});
root.querySelector("[data-control='next']")?.addEventListener("click", () => {
if (realMode) return;
if (!simulationMode) return;
transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1);
startTimer();
});
dom.play?.addEventListener("click", () => {
if (realMode) return;
if (!simulationMode) return;
state.playing = !state.playing;
text(dom.play, state.playing ? "Pause" : "Play");
if (state.playing) startTimer(); else stopTimer();
});
dom.speed?.addEventListener("input", () => {
if (realMode) return;
if (!simulationMode) return;
state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200;
startTimer();
});