Phase 20AB – Resume the Story Intelligence Roadmap

This commit is contained in:
Nick Beckley 2026-07-06 20:09:00 +01:00
parent 4b7af17b42
commit 8d493afb43
9 changed files with 706 additions and 41 deletions

View File

@ -1,13 +1,18 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Hubs;
[Authorize]
public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntelligence) : Hub
public sealed class StoryIntelligenceHub(
IStoryIntelligenceService storyIntelligence,
IStoryIntelligenceResultRepository persistedRuns,
IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub
{
public override async Task OnConnectedAsync()
{
@ -26,8 +31,80 @@ public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntellig
return await storyIntelligence.GetProgressForUserAsync(userId, jobId);
}
public async Task<StoryIntelligenceRunProgressEvent?> WatchStoryIntelligenceRun(int runId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
var run = await persistedRuns.GetRunAsync(runId);
return run is null || run.UserID != userId
? null
: ToRunProgress(run, "Current status");
}
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
return await onboardingStoryIntelligence.GetProgressAsync(batchId);
}
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType)
{
var completedScenes = run.CompletedScenes ?? 0;
var totalScenes = run.TotalDetectedScenes ?? 0;
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
return new StoryIntelligenceRunProgressEvent
{
RunID = run.StoryIntelligenceRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = run.ChapterNumber,
Status = run.Status,
CurrentStage = run.CurrentStage ?? string.Empty,
CurrentMessage = run.CurrentMessage ?? string.Empty,
EventType = eventType,
TotalDetectedScenes = run.TotalDetectedScenes,
CompletedScenes = run.CompletedScenes,
FailedScenes = run.FailedScenes,
TotalTokens = run.TotalTokens,
TotalDurationMs = elapsedMs,
ElapsedTime = FormatDuration(elapsedMs),
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes),
ErrorMessage = run.ErrorMessage,
UpdatedUtc = run.UpdatedUtc,
IsActive = run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running,
IsCompleted = run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
};
}
private static string FormatDuration(long? milliseconds)
{
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0));
if (elapsed.TotalMinutes >= 1)
{
return $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min";
}
return $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec";
}
private static string? EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes)
{
if (completedScenes <= 0 || totalScenes <= completedScenes)
{
return totalScenes > 0 && totalScenes == completedScenes ? "Nearly finished" : "Calculating";
}
var averageSceneMs = elapsedMs / Math.Max(1, completedScenes);
return FormatDuration(averageSceneMs * (totalScenes - completedScenes));
}
private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence.");

View File

@ -65,6 +65,33 @@ public sealed class StoryIntelligenceJobProgress
public bool IsCompleted { get; init; }
}
public sealed class StoryIntelligenceRunProgressEvent
{
public int RunID { get; init; }
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public int? ChapterID { get; init; }
public decimal? ChapterNumber { get; init; }
public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending;
public string CurrentStage { get; init; } = string.Empty;
public string CurrentMessage { get; init; } = string.Empty;
public string EventType { get; init; } = string.Empty;
public int? TotalDetectedScenes { get; init; }
public int? CompletedScenes { get; init; }
public int? FailedScenes { get; init; }
public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; }
public string ElapsedTime { get; init; } = "0 sec";
public string? EstimatedRemaining { get; init; }
public string? LatestSceneSummary { get; init; }
public IReadOnlyList<string> RecentDiscoveries { get; init; } = [];
public string? ErrorMessage { get; init; }
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
public bool IsActive { get; init; }
public bool IsCompleted { get; init; }
}
public sealed class StoryIntelligenceOptions
{
public string ApiKey { get; init; } = string.Empty;

View File

@ -206,6 +206,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();

View File

@ -23,6 +23,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
ICurrentUserService currentUser,
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
{
@ -141,6 +142,23 @@ public sealed class OnboardingStoryIntelligenceService(
ChapterID = chapterId,
RunID = runId
});
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
{
RunID = runId,
UserID = userId,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
ChapterID = chapterId,
ChapterNumber = chapter.Decision.ChapterNumber,
Status = StoryIntelligenceRunStatuses.Pending,
CurrentStage = "Queued",
CurrentMessage = $"{chapter.Decision.Title} is queued for analysis.",
EventType = "Import started",
ElapsedTime = "0 sec",
EstimatedRemaining = "Calculating",
IsActive = true
});
}
var batch = new OnboardingStoryIntelligenceBatch

View File

@ -21,6 +21,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IStorySceneValidator sceneValidator,
IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceProgressNotifier notifier,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
@ -61,6 +62,14 @@ public sealed class PersistedStoryIntelligenceRunner(
try
{
await PublishAsync(
run,
"Import started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence has started reading this chapter.",
stopwatch.ElapsedMilliseconds);
if (string.IsNullOrWhiteSpace(run.SourceText))
{
throw new StoryIntelligenceRunFailureException(
@ -88,6 +97,13 @@ public sealed class PersistedStoryIntelligenceRunner(
currentStage: StoryIntelligenceFailureStages.ChapterStructure,
currentMessage: "Running Chapter Structure analysis.",
totalDurationMs: stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Chapter started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.ChapterStructure,
"PlotDirector is finding the scene boundaries in this chapter.",
stopwatch.ElapsedMilliseconds);
var chapterClientResult = await client.ExecutePromptAsync(
chapterPrompt,
@ -202,6 +218,17 @@ public sealed class PersistedStoryIntelligenceRunner(
totalDurationMs: stopwatch.ElapsedMilliseconds,
estimatedCostUSD: totals.EstimatedCostUSD,
estimatedCostGBP: totals.EstimatedCostGBP);
await PublishAsync(
run,
"Scene boundaries detected",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneSplit,
$"Detected {sceneBlocks.Count:N0} suggested scene(s).",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
foreach (var block in sceneBlocks)
@ -210,6 +237,17 @@ public sealed class PersistedStoryIntelligenceRunner(
if (!block.SplitValid)
{
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, "Scene boundary range is invalid for the submitted chapter paragraphs.");
await PublishAsync(
run,
"Scene analysis failed",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence,
$"Suggested scene {block.TemporarySceneNumber:N0} could not be analysed because its paragraph range was invalid.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
continue;
}
@ -220,6 +258,17 @@ public sealed class PersistedStoryIntelligenceRunner(
completedScenes: completedScenes,
failedScenes: failedScenes,
totalDurationMs: stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Scene analysis started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence,
$"Reading scene {block.TemporarySceneNumber:N0}.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
try
{
@ -281,6 +330,19 @@ public sealed class PersistedStoryIntelligenceRunner(
});
completedScenes++;
await PublishAsync(
run,
"Scene analysis completed",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence,
$"Scene {block.TemporarySceneNumber:N0} analysed.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens,
latestSceneSummary: TrimOptional(sceneAttempt.Parsed?.Summary?.Short, 220),
recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed));
if (!validation.IsValid || validation.Warnings.Count > 0)
{
failedScenes += validation.Errors.Count > 0 ? 1 : 0;
@ -290,6 +352,18 @@ public sealed class PersistedStoryIntelligenceRunner(
{
failedScenes++;
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message, ex as AiJsonParseException);
await PublishAsync(
run,
"Scene analysis failed",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence,
$"Scene {block.TemporarySceneNumber:N0} needs attention.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens,
errorMessage: ex.Message);
logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber);
}
@ -318,10 +392,30 @@ public sealed class PersistedStoryIntelligenceRunner(
stopwatch.ElapsedMilliseconds,
totals.EstimatedCostGBP,
totals.EstimatedCostUSD);
await PublishAsync(
run,
finalStatus == StoryIntelligenceRunStatuses.Completed ? "Import completed" : "Chapter completed",
finalStatus,
StoryIntelligenceRunStatuses.Completed,
finalStatus == StoryIntelligenceRunStatuses.Completed
? "Chapter analysis complete."
: "Chapter analysis complete with notes to review.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
}
catch (StoryIntelligenceRunCancelledException)
{
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Import cancelled",
StoryIntelligenceRunStatuses.Cancelled,
currentFailureStage,
"Analysis was cancelled.",
stopwatch.ElapsedMilliseconds);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
@ -331,10 +425,26 @@ public sealed class PersistedStoryIntelligenceRunner(
"Story Intelligence request timed out or was cancelled before the run completed.",
ex.ToString(),
stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Import failed",
StoryIntelligenceRunStatuses.Failed,
currentFailureStage,
"Analysis could not finish.",
stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message);
}
catch (StoryIntelligenceRunFailureException ex)
{
await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Import failed",
StoryIntelligenceRunStatuses.Failed,
ex.FailureStage,
"Analysis could not finish.",
stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@ -344,6 +454,14 @@ public sealed class PersistedStoryIntelligenceRunner(
ex.Message,
ex.ToString(),
stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Import failed",
StoryIntelligenceRunStatuses.Failed,
ClassifyFailureStage(ex),
"Analysis could not finish.",
stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message);
}
}
@ -358,6 +476,144 @@ public sealed class PersistedStoryIntelligenceRunner(
}
}
private Task PublishAsync(
StoryIntelligenceQueuedRun run,
string eventType,
string status,
string currentStage,
string currentMessage,
long elapsedMs,
int? totalDetectedScenes = null,
int? completedScenes = null,
int? failedScenes = null,
int? totalTokens = null,
string? latestSceneSummary = null,
IReadOnlyList<string>? recentDiscoveries = null,
string? errorMessage = null)
=> notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
{
RunID = run.StoryIntelligenceRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = run.ChapterNumber,
Status = status,
CurrentStage = currentStage,
CurrentMessage = currentMessage,
EventType = eventType,
TotalDetectedScenes = totalDetectedScenes,
CompletedScenes = completedScenes,
FailedScenes = failedScenes,
TotalTokens = totalTokens,
TotalDurationMs = elapsedMs,
ElapsedTime = FormatDuration(elapsedMs),
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes ?? 0, totalDetectedScenes ?? 0),
LatestSceneSummary = latestSceneSummary,
RecentDiscoveries = recentDiscoveries ?? [],
ErrorMessage = errorMessage,
UpdatedUtc = DateTime.UtcNow,
IsActive = status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running,
IsCompleted = status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
});
private static string FormatDuration(long? milliseconds)
{
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0));
if (elapsed.TotalMinutes >= 1)
{
return $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min";
}
return $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec";
}
private static string? EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes)
{
if (totalScenes > 0 && completedScenes >= totalScenes)
{
return "Nearly finished";
}
if (completedScenes <= 0 || totalScenes <= 0)
{
return "Calculating";
}
var remainingScenes = Math.Max(0, totalScenes - completedScenes);
var averageSceneMs = elapsedMs / Math.Max(1, completedScenes);
return FormatDuration(averageSceneMs * remainingScenes);
}
private static IReadOnlyList<string> BuildDiscoveries(SceneIntelligenceScene? scene)
{
if (scene is null)
{
return [];
}
var discoveries = new List<string>();
discoveries.AddRange((scene.Characters ?? [])
.Select(character => TrimOptional(character.Name, 120))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.Select(name => $"Character detected: {name}"));
discoveries.AddRange((scene.Locations ?? [])
.Select(location => TrimOptional(location.Name, 120))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.Select(name => $"Location detected: {name}"));
discoveries.AddRange((scene.Assets ?? [])
.Select(asset => TrimOptional(asset.Name, 120))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(2)
.Select(name => $"Asset detected: {name}"));
discoveries.AddRange((scene.Relationships ?? [])
.Select(relationship => BuildRelationshipDiscovery(relationship))
.Where(text => !string.IsNullOrWhiteSpace(text))
.OfType<string>()
.Take(2));
discoveries.AddRange((scene.TimelineClues ?? [])
.Select(clue => TrimOptional(clue.Clue, 160))
.Where(text => !string.IsNullOrWhiteSpace(text))
.Take(2)
.Select(text => $"Timeline clue detected: {text}"));
return discoveries
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(8)
.ToList();
}
private static string? BuildRelationshipDiscovery(SceneIntelligenceRelationship relationship)
{
var characterA = TrimOptional(relationship.CharacterA, 80);
var characterB = TrimOptional(relationship.CharacterB, 80);
var signal = TrimOptional(relationship.RelationshipSignal, 120);
if (string.IsNullOrWhiteSpace(characterA) || string.IsNullOrWhiteSpace(characterB))
{
return null;
}
return string.IsNullOrWhiteSpace(signal)
? $"Relationship detected: {characterA} and {characterB}"
: $"Relationship detected: {characterA} and {characterB} - {signal}";
}
private static string? TrimOptional(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd();
}
private async Task<AiJsonParseResult<T>> ParseAiJsonWithRetryAsync<T>(
string completedPrompt,
string promptVersion,

View File

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.SignalR;
using PlotLine.Hubs;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceProgressNotifier
{
Task PublishAsync(StoryIntelligenceRunProgressEvent progress);
}
public sealed class StoryIntelligenceProgressNotifier(IHubContext<StoryIntelligenceHub> hub) : IStoryIntelligenceProgressNotifier
{
public Task PublishAsync(StoryIntelligenceRunProgressEvent progress)
=> hub.Clients.Group(StoryIntelligenceHub.UserGroup(progress.UserID))
.SendAsync("StoryIntelligenceRunProgressChanged", progress);
}

View File

@ -8,7 +8,7 @@
<div class="onboarding-copy">
<p class="eyebrow">@Model.SelectedBookTitle</p>
<h1 id="story-intelligence-title">Analyse chapters with Story Intelligence</h1>
<p>PlotDirector will read the approved chapter text, detect scene boundaries, and prepare scenes for your review before anything is created.</p>
<p>PlotDirector can now analyse your manuscript to identify scenes and begin understanding your story.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -42,9 +42,9 @@
<p>Chapter text ready for analysis.</p>
</div>
<div>
<span>Creates</span>
<strong>Scenes only</strong>
<p>Characters, locations, assets and relationships will not be imported yet.</p>
<span>Estimated time</span>
<strong>@EstimateDuration(Model.ApprovedWordCount)</strong>
<p>The analysis runs in the background, so you can leave this page and return later.</p>
</div>
<div>
<span>Review</span>
@ -53,6 +53,11 @@
</div>
</div>
<section class="onboarding-form-section">
<p>It will extract scene summaries, scene purpose, characters, locations, assets, relationships, timeline clues and story observations.</p>
<p>For this step, only scenes are created in PlotDirector. The other discoveries are prepared for later review stages.</p>
</section>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<section class="onboarding-form-section">
@ -75,3 +80,16 @@
}
</div>
</section>
@functions {
private static string EstimateDuration(int wordCount)
{
if (wordCount <= 0)
{
return "A few minutes";
}
var minutes = Math.Clamp((int)Math.Ceiling(wordCount / 3000m) * 2, 2, 30);
return minutes <= 2 ? "About 2 minutes" : $"About {minutes:N0} minutes";
}
}

View File

@ -1,19 +1,20 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review scenes";
var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID));
var progressPercent = ProgressPercent(Model);
}
<section class="onboarding-shell" aria-labelledby="story-progress-title">
<div class="onboarding-panel onboarding-review-panel">
@if (Model.HasActiveRuns)
{
<meta http-equiv="refresh" content="10" />
}
<div class="onboarding-panel onboarding-review-panel"
data-story-intelligence-onboarding
data-story-intelligence-batch-id="@Model.BatchID"
data-story-intelligence-run-ids="@runIds"
data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()">
<div class="onboarding-copy">
<p class="eyebrow">@Model.BookTitle</p>
<h1 id="story-progress-title">Review scenes</h1>
<p>PlotDirector is analysing the approved chapters. When a chapter is ready, you can create its scenes in PlotDirector.</p>
<p>PlotDirector is reading the approved chapters and turning them into scenes you can review before creating them.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -27,60 +28,90 @@
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Chapters</span>
<strong>@Model.CompletedChapterCount / @Model.ChapterCount</strong>
<span>Overall progress</span>
<strong data-story-overall-percent>@progressPercent%</strong>
</div>
<div>
<span>Scenes detected</span>
<strong>@Model.TotalDetectedScenes</strong>
<span>Chapters completed</span>
<strong><span data-story-chapters-completed>@Model.CompletedChapterCount</span> / @Model.ChapterCount</strong>
</div>
<div>
<span>Scenes analysed</span>
<strong>@Model.TotalCompletedScenes</strong>
<strong><span data-story-scenes-completed>@Model.TotalCompletedScenes</span> / <span data-story-scenes-total>@Model.TotalDetectedScenes</span></strong>
</div>
<div>
<span>Failures</span>
<strong>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
<strong data-story-failures>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
</div>
</div>
<section class="onboarding-review-grid" aria-label="Chapter analysis progress">
<div class="progress mb-3" role="progressbar" aria-label="Story Intelligence progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="@progressPercent">
<div class="progress-bar" data-story-overall-progress-bar style="width:@progressPercent%"></div>
</div>
<section class="onboarding-review-grid" aria-label="Story Intelligence progress">
<div class="onboarding-review-section">
<h2>Reading now</h2>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Current chapter</span>
<strong data-story-current-chapter>@(CurrentChapter(Model)?.ChapterTitle ?? "Waiting")</strong>
</div>
<div>
<span>Current stage</span>
<strong data-story-current-stage>@(CurrentChapter(Model)?.CurrentStage ?? "Queued")</strong>
</div>
<div>
<span>Elapsed</span>
<strong data-story-elapsed>@Elapsed(Model)</strong>
</div>
<div>
<span>Estimated remaining</span>
<strong data-story-remaining>Calculating</strong>
</div>
</div>
<p data-story-current-message>@(CurrentChapter(Model)?.CurrentMessage ?? "Waiting for analysis to begin.")</p>
<p class="lead" data-story-latest-summary hidden></p>
<ul data-story-discovery-feed></ul>
</div>
<div class="onboarding-review-section">
<h2>Chapters</h2>
<div class="onboarding-review-chapters">
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{
<details class="onboarding-review-chapter" open>
<details class="onboarding-review-chapter" open data-story-run-id="@chapter.RunID" data-story-chapter-title="@chapter.ChapterTitle">
<summary>
<span>@chapter.ChapterNumber. @chapter.ChapterTitle</span>
<strong>@ChapterStatusLabel(chapter)</strong>
<strong data-story-chapter-status-label>@ChapterStatusLabel(chapter)</strong>
</summary>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Status</span>
<strong>@chapter.Status</strong>
<strong data-story-run-status>@chapter.Status</strong>
</div>
<div>
<span>Scenes</span>
<strong>@((chapter.CompletedScenes ?? 0).ToString("N0")) / @((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</strong>
<strong><span data-story-run-completed>@((chapter.CompletedScenes ?? 0).ToString("N0"))</span> / <span data-story-run-total>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</span></strong>
</div>
<div>
<span>Failed</span>
<strong>@((chapter.FailedScenes ?? 0).ToString("N0"))</strong>
<strong data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</strong>
</div>
<div>
<span>Tokens</span>
<strong>@(chapter.TotalTokens?.ToString("N0") ?? "-")</strong>
<strong data-story-run-tokens>@(chapter.TotalTokens?.ToString("N0") ?? "-")</strong>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(chapter.CurrentMessage))
{
<p>@chapter.CurrentMessage</p>
}
<p data-story-run-message>@(chapter.CurrentMessage ?? string.Empty)</p>
@if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage))
{
<p class="text-danger">@chapter.ErrorMessage</p>
<p class="text-danger" data-story-run-error>@chapter.ErrorMessage</p>
}
else
{
<p class="text-danger" data-story-run-error hidden></p>
}
@if (chapter.Warnings.Any())
{
@ -132,7 +163,6 @@
<input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
</form>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">Refresh</a>
<a class="btn btn-primary @(Model.AllCommitted ? string.Empty : "disabled")"
asp-action="StoryIntelligenceComplete"
asp-route-batchId="@Model.BatchID"
@ -142,6 +172,10 @@
</div>
</section>
@section Scripts {
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
}
@functions {
private static string ChapterStatusLabel(StoryIntelligenceOnboardingChapterViewModel chapter)
{
@ -167,4 +201,33 @@
return "Reviewing";
}
private static StoryIntelligenceOnboardingChapterViewModel? CurrentChapter(StoryIntelligenceProgressViewModel model)
=> model.Chapters.FirstOrDefault(chapter => chapter.IsActive)
?? model.Chapters.LastOrDefault(chapter => chapter.IsRunComplete)
?? model.Chapters.FirstOrDefault();
private static int ProgressPercent(StoryIntelligenceProgressViewModel model)
{
if (model.ChapterCount == 0)
{
return 0;
}
if (model.TotalDetectedScenes > 0)
{
return Math.Clamp((int)Math.Round(model.TotalCompletedScenes * 100m / Math.Max(1, model.TotalDetectedScenes)), 0, 100);
}
return Math.Clamp((int)Math.Round(model.CompletedChapterCount * 100m / Math.Max(1, model.ChapterCount)), 0, 100);
}
private static string Elapsed(StoryIntelligenceProgressViewModel model)
{
var elapsedMs = model.Chapters.Max(chapter => chapter.TotalDurationMs ?? 0);
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, elapsedMs));
return elapsed.TotalMinutes >= 1
? $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min"
: $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec";
}
}

View File

@ -1,15 +1,26 @@
(() => {
const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")];
const dashboardRoots = [...document.querySelectorAll("[data-story-intelligence-dashboard]")];
const roots = [...progressRoots, ...dashboardRoots];
const onboardingRoots = [...document.querySelectorAll("[data-story-intelligence-onboarding]")];
const legacyRoots = [...progressRoots, ...dashboardRoots];
const roots = [...legacyRoots, ...onboardingRoots];
if (roots.length === 0 || !window.signalR) {
return;
}
const field = (state, camel, pascal) => state?.[camel] ?? state?.[pascal] ?? null;
const jobIds = [...new Set(roots.map((root) => root.dataset.storyIntelligenceJobId).filter(Boolean))];
const legacyJobIds = [...new Set(legacyRoots.map((root) => root.dataset.storyIntelligenceJobId).filter(Boolean))];
const runIds = [...new Set(onboardingRoots
.flatMap((root) => (root.dataset.storyIntelligenceRunIds || "").split(","))
.map((value) => value.trim())
.filter(Boolean))];
const updateRoot = (root, state) => {
const numberText = (value) => {
const number = Number.parseInt(value ?? "0", 10);
return Number.isFinite(number) ? number.toLocaleString() : "0";
};
const updateLegacyRoot = (root, state) => {
const percent = field(state, "progressPercent", "ProgressPercent") ?? 0;
const status = field(state, "status", "Status") || "Pending";
const stage = field(state, "currentStage", "CurrentStage") || status;
@ -46,33 +57,210 @@
});
};
const applyProgress = (state) => {
const applyLegacyProgress = (state) => {
const jobId = field(state, "jobID", "JobID") || field(state, "jobId", "JobId");
if (!jobId) {
return;
}
roots
legacyRoots
.filter((root) => root.dataset.storyIntelligenceJobId?.toLowerCase() === String(jobId).toLowerCase())
.forEach((root) => updateRoot(root, state));
.forEach((root) => updateLegacyRoot(root, state));
if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) {
window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`;
}
};
const statusLabel = (state) => {
const status = field(state, "status", "Status") || "";
if (status === "Completed" || status === "CompletedWithWarnings") {
return "Ready to create";
}
if (status === "Failed" || status === "Cancelled") {
return "Needs attention";
}
if (status === "Pending" || status === "Running") {
return "Analysing";
}
return "Reviewing";
};
const addFeedItem = (list, text) => {
if (!list || !text) {
return;
}
const item = document.createElement("li");
item.textContent = text;
list.prepend(item);
while (list.children.length > 8) {
list.lastElementChild?.remove();
}
};
const recalculateOnboardingTotals = (root) => {
const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true";
const chapters = [...root.querySelectorAll("[data-story-run-id]")];
const totals = chapters.reduce((current, chapter) => {
const status = chapter.querySelector("[data-story-run-status]")?.textContent?.trim() || "";
const completed = Number.parseInt(chapter.querySelector("[data-story-run-completed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0;
const total = Number.parseInt(chapter.querySelector("[data-story-run-total]")?.textContent?.replace(/,/g, "") || "0", 10) || 0;
const failed = Number.parseInt(chapter.querySelector("[data-story-run-failed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0;
current.completedChapters += status === "Completed" || status === "CompletedWithWarnings" ? 1 : 0;
current.failedChapters += status === "Failed" || status === "Cancelled" ? 1 : 0;
current.completedScenes += completed;
current.totalScenes += total;
current.failedScenes += failed;
return current;
}, { completedChapters: 0, failedChapters: 0, completedScenes: 0, totalScenes: 0, failedScenes: 0 });
const percent = totals.totalScenes > 0
? Math.round((totals.completedScenes * 100) / Math.max(1, totals.totalScenes))
: Math.round((totals.completedChapters * 100) / Math.max(1, chapters.length));
root.querySelectorAll("[data-story-overall-percent]").forEach((node) => {
node.textContent = `${Math.max(0, Math.min(100, percent))}%`;
});
root.querySelectorAll("[data-story-overall-progress-bar]").forEach((node) => {
node.style.width = `${Math.max(0, Math.min(100, percent))}%`;
});
root.querySelectorAll("[data-story-chapters-completed]").forEach((node) => {
node.textContent = numberText(totals.completedChapters);
});
root.querySelectorAll("[data-story-scenes-completed]").forEach((node) => {
node.textContent = numberText(totals.completedScenes);
});
root.querySelectorAll("[data-story-scenes-total]").forEach((node) => {
node.textContent = numberText(totals.totalScenes);
});
root.querySelectorAll("[data-story-failures]").forEach((node) => {
node.textContent = numberText(totals.failedChapters + totals.failedScenes);
});
const active = chapters.some((chapter) => {
const status = chapter.querySelector("[data-story-run-status]")?.textContent?.trim();
return status === "Pending" || status === "Running";
});
root.dataset.storyIntelligenceHasActiveRuns = String(active);
if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") {
root.dataset.reloadScheduled = "true";
window.setTimeout(() => window.location.reload(), 1200);
}
};
const applyRunProgress = (state) => {
const runId = field(state, "runID", "RunID") || field(state, "runId", "RunId");
if (!runId) {
return;
}
onboardingRoots.forEach((root) => {
const chapter = root.querySelector(`[data-story-run-id="${runId}"]`);
if (!chapter) {
return;
}
const status = field(state, "status", "Status") || "Pending";
const stage = field(state, "currentStage", "CurrentStage") || status;
const message = field(state, "currentMessage", "CurrentMessage") || "";
const completed = field(state, "completedScenes", "CompletedScenes");
const total = field(state, "totalDetectedScenes", "TotalDetectedScenes");
const failed = field(state, "failedScenes", "FailedScenes");
const tokens = field(state, "totalTokens", "TotalTokens");
const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec";
const remaining = field(state, "estimatedRemaining", "EstimatedRemaining") || "Calculating";
const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary");
const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || [];
const error = field(state, "errorMessage", "ErrorMessage");
chapter.querySelectorAll("[data-story-chapter-status-label]").forEach((node) => {
node.textContent = statusLabel(state);
});
chapter.querySelectorAll("[data-story-run-status]").forEach((node) => {
node.textContent = status;
});
chapter.querySelectorAll("[data-story-run-message]").forEach((node) => {
node.textContent = message;
});
chapter.querySelectorAll("[data-story-run-completed]").forEach((node) => {
if (completed !== null) {
node.textContent = numberText(completed);
}
});
chapter.querySelectorAll("[data-story-run-total]").forEach((node) => {
if (total !== null) {
node.textContent = numberText(total);
}
});
chapter.querySelectorAll("[data-story-run-failed]").forEach((node) => {
if (failed !== null) {
node.textContent = numberText(failed);
}
});
chapter.querySelectorAll("[data-story-run-tokens]").forEach((node) => {
node.textContent = tokens === null ? "-" : numberText(tokens);
});
chapter.querySelectorAll("[data-story-run-error]").forEach((node) => {
node.textContent = error || "";
node.hidden = !error;
});
root.querySelectorAll("[data-story-current-chapter]").forEach((node) => {
node.textContent = chapter.dataset.storyChapterTitle || "Current chapter";
});
root.querySelectorAll("[data-story-current-stage]").forEach((node) => {
node.textContent = stage;
});
root.querySelectorAll("[data-story-current-message]").forEach((node) => {
node.textContent = message;
});
root.querySelectorAll("[data-story-elapsed]").forEach((node) => {
node.textContent = elapsed;
});
root.querySelectorAll("[data-story-remaining]").forEach((node) => {
node.textContent = remaining;
});
const summaryNode = root.querySelector("[data-story-latest-summary]");
if (summaryNode && latestSummary) {
summaryNode.textContent = latestSummary;
summaryNode.hidden = false;
}
const feed = root.querySelector("[data-story-discovery-feed]");
[...discoveries].reverse().forEach((discovery) => addFeedItem(feed, discovery));
recalculateOnboardingTotals(root);
});
};
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/story-intelligence")
.withAutomaticReconnect()
.build();
connection.on("StoryIntelligenceProgressChanged", applyProgress);
connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress);
connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress);
connection.onreconnected(() => {
jobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyProgress).catch(() => {}));
legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {}));
runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {}));
});
connection.start()
.then(() => Promise.all(jobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId))))
.then((states) => states.filter(Boolean).forEach(applyProgress))
.then(() => Promise.all([
...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)),
...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)))
]))
.then((states) => {
states.filter(Boolean).forEach((state) => {
if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) {
applyRunProgress(state);
} else {
applyLegacyProgress(state);
}
});
})
.catch(() => {});
})();