Phase 20AB – Resume the Story Intelligence Roadmap
This commit is contained in:
parent
4b7af17b42
commit
8d493afb43
@ -1,13 +1,18 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
namespace PlotLine.Hubs;
|
namespace PlotLine.Hubs;
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntelligence) : Hub
|
public sealed class StoryIntelligenceHub(
|
||||||
|
IStoryIntelligenceService storyIntelligence,
|
||||||
|
IStoryIntelligenceResultRepository persistedRuns,
|
||||||
|
IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub
|
||||||
{
|
{
|
||||||
public override async Task OnConnectedAsync()
|
public override async Task OnConnectedAsync()
|
||||||
{
|
{
|
||||||
@ -26,8 +31,80 @@ public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntellig
|
|||||||
return await storyIntelligence.GetProgressForUserAsync(userId, jobId);
|
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}";
|
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()
|
private int RequireUserId()
|
||||||
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence.");
|
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence.");
|
||||||
|
|
||||||
|
|||||||
@ -65,6 +65,33 @@ public sealed class StoryIntelligenceJobProgress
|
|||||||
public bool IsCompleted { get; init; }
|
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 sealed class StoryIntelligenceOptions
|
||||||
{
|
{
|
||||||
public string ApiKey { get; init; } = string.Empty;
|
public string ApiKey { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -206,6 +206,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
|
|||||||
@ -23,6 +23,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceImportCommitService commits,
|
IStoryIntelligenceImportCommitService commits,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
|
IStoryIntelligenceProgressNotifier notifier,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
|
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
|
||||||
{
|
{
|
||||||
@ -141,6 +142,23 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
RunID = runId
|
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
|
var batch = new OnboardingStoryIntelligenceBatch
|
||||||
|
|||||||
@ -21,6 +21,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
IStorySceneValidator sceneValidator,
|
IStorySceneValidator sceneValidator,
|
||||||
IOptions<StoryIntelligenceOptions> options,
|
IOptions<StoryIntelligenceOptions> options,
|
||||||
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
||||||
|
IStoryIntelligenceProgressNotifier notifier,
|
||||||
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
|
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
|
||||||
{
|
{
|
||||||
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
|
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
|
||||||
@ -61,6 +62,14 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await PublishAsync(
|
||||||
|
run,
|
||||||
|
"Import started",
|
||||||
|
StoryIntelligenceRunStatuses.Running,
|
||||||
|
StoryIntelligenceFailureStages.DocumentRead,
|
||||||
|
"Story Intelligence has started reading this chapter.",
|
||||||
|
stopwatch.ElapsedMilliseconds);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(run.SourceText))
|
if (string.IsNullOrWhiteSpace(run.SourceText))
|
||||||
{
|
{
|
||||||
throw new StoryIntelligenceRunFailureException(
|
throw new StoryIntelligenceRunFailureException(
|
||||||
@ -88,6 +97,13 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
currentStage: StoryIntelligenceFailureStages.ChapterStructure,
|
currentStage: StoryIntelligenceFailureStages.ChapterStructure,
|
||||||
currentMessage: "Running Chapter Structure analysis.",
|
currentMessage: "Running Chapter Structure analysis.",
|
||||||
totalDurationMs: stopwatch.ElapsedMilliseconds);
|
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(
|
var chapterClientResult = await client.ExecutePromptAsync(
|
||||||
chapterPrompt,
|
chapterPrompt,
|
||||||
@ -202,6 +218,17 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
totalDurationMs: stopwatch.ElapsedMilliseconds,
|
totalDurationMs: stopwatch.ElapsedMilliseconds,
|
||||||
estimatedCostUSD: totals.EstimatedCostUSD,
|
estimatedCostUSD: totals.EstimatedCostUSD,
|
||||||
estimatedCostGBP: totals.EstimatedCostGBP);
|
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);
|
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
|
||||||
foreach (var block in sceneBlocks)
|
foreach (var block in sceneBlocks)
|
||||||
@ -210,6 +237,17 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
if (!block.SplitValid)
|
if (!block.SplitValid)
|
||||||
{
|
{
|
||||||
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, "Scene boundary range is invalid for the submitted chapter paragraphs.");
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,6 +258,17 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
completedScenes: completedScenes,
|
completedScenes: completedScenes,
|
||||||
failedScenes: failedScenes,
|
failedScenes: failedScenes,
|
||||||
totalDurationMs: stopwatch.ElapsedMilliseconds);
|
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
|
try
|
||||||
{
|
{
|
||||||
@ -281,6 +330,19 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
});
|
});
|
||||||
|
|
||||||
completedScenes++;
|
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)
|
if (!validation.IsValid || validation.Warnings.Count > 0)
|
||||||
{
|
{
|
||||||
failedScenes += validation.Errors.Count > 0 ? 1 : 0;
|
failedScenes += validation.Errors.Count > 0 ? 1 : 0;
|
||||||
@ -290,6 +352,18 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
{
|
{
|
||||||
failedScenes++;
|
failedScenes++;
|
||||||
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message, ex as AiJsonParseException);
|
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);
|
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,
|
stopwatch.ElapsedMilliseconds,
|
||||||
totals.EstimatedCostGBP,
|
totals.EstimatedCostGBP,
|
||||||
totals.EstimatedCostUSD);
|
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)
|
catch (StoryIntelligenceRunCancelledException)
|
||||||
{
|
{
|
||||||
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds);
|
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)
|
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.",
|
"Story Intelligence request timed out or was cancelled before the run completed.",
|
||||||
ex.ToString(),
|
ex.ToString(),
|
||||||
stopwatch.ElapsedMilliseconds);
|
stopwatch.ElapsedMilliseconds);
|
||||||
|
await PublishAsync(
|
||||||
|
run,
|
||||||
|
"Import failed",
|
||||||
|
StoryIntelligenceRunStatuses.Failed,
|
||||||
|
currentFailureStage,
|
||||||
|
"Analysis could not finish.",
|
||||||
|
stopwatch.ElapsedMilliseconds,
|
||||||
|
errorMessage: ex.Message);
|
||||||
}
|
}
|
||||||
catch (StoryIntelligenceRunFailureException ex)
|
catch (StoryIntelligenceRunFailureException ex)
|
||||||
{
|
{
|
||||||
await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds);
|
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)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
@ -344,6 +454,14 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
ex.Message,
|
ex.Message,
|
||||||
ex.ToString(),
|
ex.ToString(),
|
||||||
stopwatch.ElapsedMilliseconds);
|
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>(
|
private async Task<AiJsonParseResult<T>> ParseAiJsonWithRetryAsync<T>(
|
||||||
string completedPrompt,
|
string completedPrompt,
|
||||||
string promptVersion,
|
string promptVersion,
|
||||||
|
|||||||
17
PlotLine/Services/StoryIntelligenceProgressNotifier.cs
Normal file
17
PlotLine/Services/StoryIntelligenceProgressNotifier.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@
|
|||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">@Model.SelectedBookTitle</p>
|
<p class="eyebrow">@Model.SelectedBookTitle</p>
|
||||||
<h1 id="story-intelligence-title">Analyse chapters with Story Intelligence</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||||
@ -42,9 +42,9 @@
|
|||||||
<p>Chapter text ready for analysis.</p>
|
<p>Chapter text ready for analysis.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Creates</span>
|
<span>Estimated time</span>
|
||||||
<strong>Scenes only</strong>
|
<strong>@EstimateDuration(Model.ApprovedWordCount)</strong>
|
||||||
<p>Characters, locations, assets and relationships will not be imported yet.</p>
|
<p>The analysis runs in the background, so you can leave this page and return later.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Review</span>
|
<span>Review</span>
|
||||||
@ -53,6 +53,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</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))
|
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||||
{
|
{
|
||||||
<section class="onboarding-form-section">
|
<section class="onboarding-form-section">
|
||||||
@ -75,3 +80,16 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
@model StoryIntelligenceProgressViewModel
|
@model StoryIntelligenceProgressViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Review scenes";
|
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">
|
<section class="onboarding-shell" aria-labelledby="story-progress-title">
|
||||||
<div class="onboarding-panel onboarding-review-panel">
|
<div class="onboarding-panel onboarding-review-panel"
|
||||||
@if (Model.HasActiveRuns)
|
data-story-intelligence-onboarding
|
||||||
{
|
data-story-intelligence-batch-id="@Model.BatchID"
|
||||||
<meta http-equiv="refresh" content="10" />
|
data-story-intelligence-run-ids="@runIds"
|
||||||
}
|
data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()">
|
||||||
|
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">@Model.BookTitle</p>
|
<p class="eyebrow">@Model.BookTitle</p>
|
||||||
<h1 id="story-progress-title">Review scenes</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||||
@ -27,60 +28,90 @@
|
|||||||
|
|
||||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||||
<div>
|
<div>
|
||||||
<span>Chapters</span>
|
<span>Overall progress</span>
|
||||||
<strong>@Model.CompletedChapterCount / @Model.ChapterCount</strong>
|
<strong data-story-overall-percent>@progressPercent%</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Scenes detected</span>
|
<span>Chapters completed</span>
|
||||||
<strong>@Model.TotalDetectedScenes</strong>
|
<strong><span data-story-chapters-completed>@Model.CompletedChapterCount</span> / @Model.ChapterCount</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Scenes analysed</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span>Failures</span>
|
<span>Failures</span>
|
||||||
<strong>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
|
<strong data-story-failures>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="onboarding-review-section">
|
||||||
<h2>Chapters</h2>
|
<h2>Chapters</h2>
|
||||||
<div class="onboarding-review-chapters">
|
<div class="onboarding-review-chapters">
|
||||||
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
|
@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>
|
<summary>
|
||||||
<span>@chapter.ChapterNumber. @chapter.ChapterTitle</span>
|
<span>@chapter.ChapterNumber. @chapter.ChapterTitle</span>
|
||||||
<strong>@ChapterStatusLabel(chapter)</strong>
|
<strong data-story-chapter-status-label>@ChapterStatusLabel(chapter)</strong>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||||
<div>
|
<div>
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
<strong>@chapter.Status</strong>
|
<strong data-story-run-status>@chapter.Status</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Scenes</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span>Failed</span>
|
<span>Failed</span>
|
||||||
<strong>@((chapter.FailedScenes ?? 0).ToString("N0"))</strong>
|
<strong data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Tokens</span>
|
<span>Tokens</span>
|
||||||
<strong>@(chapter.TotalTokens?.ToString("N0") ?? "-")</strong>
|
<strong data-story-run-tokens>@(chapter.TotalTokens?.ToString("N0") ?? "-")</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(chapter.CurrentMessage))
|
<p data-story-run-message>@(chapter.CurrentMessage ?? string.Empty)</p>
|
||||||
{
|
|
||||||
<p>@chapter.CurrentMessage</p>
|
|
||||||
}
|
|
||||||
@if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage))
|
@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())
|
@if (chapter.Warnings.Any())
|
||||||
{
|
{
|
||||||
@ -132,7 +163,6 @@
|
|||||||
<input type="hidden" name="batchId" value="@Model.BatchID" />
|
<input type="hidden" name="batchId" value="@Model.BatchID" />
|
||||||
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
|
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
|
||||||
</form>
|
</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")"
|
<a class="btn btn-primary @(Model.AllCommitted ? string.Empty : "disabled")"
|
||||||
asp-action="StoryIntelligenceComplete"
|
asp-action="StoryIntelligenceComplete"
|
||||||
asp-route-batchId="@Model.BatchID"
|
asp-route-batchId="@Model.BatchID"
|
||||||
@ -142,6 +172,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
|
|
||||||
@functions {
|
@functions {
|
||||||
private static string ChapterStatusLabel(StoryIntelligenceOnboardingChapterViewModel chapter)
|
private static string ChapterStatusLabel(StoryIntelligenceOnboardingChapterViewModel chapter)
|
||||||
{
|
{
|
||||||
@ -167,4 +201,33 @@
|
|||||||
|
|
||||||
return "Reviewing";
|
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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,26 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")];
|
const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")];
|
||||||
const dashboardRoots = [...document.querySelectorAll("[data-story-intelligence-dashboard]")];
|
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) {
|
if (roots.length === 0 || !window.signalR) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = (state, camel, pascal) => state?.[camel] ?? state?.[pascal] ?? null;
|
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 percent = field(state, "progressPercent", "ProgressPercent") ?? 0;
|
||||||
const status = field(state, "status", "Status") || "Pending";
|
const status = field(state, "status", "Status") || "Pending";
|
||||||
const stage = field(state, "currentStage", "CurrentStage") || status;
|
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");
|
const jobId = field(state, "jobID", "JobID") || field(state, "jobId", "JobId");
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
roots
|
legacyRoots
|
||||||
.filter((root) => root.dataset.storyIntelligenceJobId?.toLowerCase() === String(jobId).toLowerCase())
|
.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")) {
|
if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) {
|
||||||
window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`;
|
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()
|
const connection = new signalR.HubConnectionBuilder()
|
||||||
.withUrl("/hubs/story-intelligence")
|
.withUrl("/hubs/story-intelligence")
|
||||||
.withAutomaticReconnect()
|
.withAutomaticReconnect()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
connection.on("StoryIntelligenceProgressChanged", applyProgress);
|
connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress);
|
||||||
|
connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress);
|
||||||
connection.onreconnected(() => {
|
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()
|
connection.start()
|
||||||
.then(() => Promise.all(jobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId))))
|
.then(() => Promise.all([
|
||||||
.then((states) => states.filter(Boolean).forEach(applyProgress))
|
...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(() => {});
|
.catch(() => {});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user