Fix story intelligence progress totals
This commit is contained in:
parent
96343138ea
commit
0cc25c570c
@ -231,7 +231,8 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
|
("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
|
||||||
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
|
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
|
||||||
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
|
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
|
||||||
("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution)
|
("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution),
|
||||||
|
("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var test in tests)
|
foreach (var test in tests)
|
||||||
@ -270,6 +271,77 @@ static void StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution()
|
|||||||
Assert(openAiClient.Contains("RetryConditionHeaderValue", StringComparison.Ordinal), "OpenAI retry should honour Retry-After metadata.");
|
Assert(openAiClient.Contains("RetryConditionHeaderValue", StringComparison.Ordinal), "OpenAI retry should honour Retry-After metadata.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals()
|
||||||
|
{
|
||||||
|
var root = PlotLineRoot();
|
||||||
|
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/StoryIntelligenceGlobalProgressViewModels.cs"));
|
||||||
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceGlobalProgressService.cs"));
|
||||||
|
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
|
||||||
|
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
|
||||||
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
|
||||||
|
var migration = File.ReadAllText(Path.Combine(root, "Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql"));
|
||||||
|
|
||||||
|
var book = new StoryIntelligenceGlobalProgressBookViewModel
|
||||||
|
{
|
||||||
|
BookTitle = "Test Book",
|
||||||
|
PendingRunCount = 2,
|
||||||
|
RunningRunCount = 3,
|
||||||
|
TotalExpectedScenes = 47,
|
||||||
|
CompletedScenes = 5,
|
||||||
|
FailedScenes = 0,
|
||||||
|
ActiveScenes = 15,
|
||||||
|
TotalDurationMs = TimeSpan.FromMinutes(2).Ticks / TimeSpan.TicksPerMillisecond,
|
||||||
|
Status = StoryIntelligenceRunStatuses.Running
|
||||||
|
};
|
||||||
|
var model = new StoryIntelligenceGlobalProgressViewModel { Books = [book] };
|
||||||
|
|
||||||
|
Assert(model.TotalExpectedScenes == 47, "Global progress denominator should be the expected whole-run scene total.");
|
||||||
|
Assert(model.TotalDetectedScenes == 47, "Legacy detected-scenes accessor should mirror the expected scene total for display.");
|
||||||
|
Assert(model.CompletedScenes == 5, "Completed count should count successful completed scene analyses.");
|
||||||
|
Assert(model.ActiveScenes == 15, "Active scene count should remain distinct from the denominator.");
|
||||||
|
Assert(model.ProgressPercent == 11, "5 of 47 scenes should round to 11%, not 33% from a 15-scene active subset.");
|
||||||
|
Assert(book.SummaryText.Contains("5/47", StringComparison.Ordinal), "Book summary should display 5/47.");
|
||||||
|
|
||||||
|
var finalisingBook = new StoryIntelligenceGlobalProgressBookViewModel
|
||||||
|
{
|
||||||
|
BookTitle = "Test Book",
|
||||||
|
PendingRunCount = 0,
|
||||||
|
RunningRunCount = 1,
|
||||||
|
TotalExpectedScenes = 47,
|
||||||
|
CompletedScenes = 47,
|
||||||
|
FailedScenes = 0,
|
||||||
|
ActiveScenes = 0,
|
||||||
|
TotalDurationMs = TimeSpan.FromMinutes(8).Ticks / TimeSpan.TicksPerMillisecond,
|
||||||
|
Status = "Finalising"
|
||||||
|
};
|
||||||
|
Assert(finalisingBook.ProgressPercent == 100, "Scene progress can be visually full during finalisation.");
|
||||||
|
Assert(finalisingBook.EstimatedRemaining == "Finalising", "All scene analyses complete should show finalising until pipeline state is persisted.");
|
||||||
|
|
||||||
|
var failedBook = new StoryIntelligenceGlobalProgressBookViewModel
|
||||||
|
{
|
||||||
|
BookTitle = "Test Book",
|
||||||
|
PendingRunCount = 0,
|
||||||
|
RunningRunCount = 0,
|
||||||
|
TotalExpectedScenes = 47,
|
||||||
|
CompletedScenes = 46,
|
||||||
|
FailedScenes = 1,
|
||||||
|
ActiveScenes = 0,
|
||||||
|
TotalDurationMs = TimeSpan.FromMinutes(8).Ticks / TimeSpan.TicksPerMillisecond,
|
||||||
|
Status = StoryIntelligenceRunStatuses.CompletedWithWarnings
|
||||||
|
};
|
||||||
|
Assert(failedBook.ProgressPercent == 98, "46 success plus 1 failed must not display as 47 successful scenes.");
|
||||||
|
Assert(failedBook.EstimatedRemaining == "Finalising", "Terminal failures should be excluded from ETA while remaining visible.");
|
||||||
|
|
||||||
|
Assert(service.Contains("TotalExpectedScenes = summary.TotalExpectedScenes", StringComparison.Ordinal), "Global progress service should map total expected scenes.");
|
||||||
|
Assert(hub.Contains("WatchGlobalStoryIntelligenceProgress", StringComparison.Ordinal), "SignalR should expose the aggregate progress calculation.");
|
||||||
|
Assert(layout.Contains("TotalExpectedScenes", StringComparison.Ordinal), "Initial server-rendered strip should use total expected scenes.");
|
||||||
|
Assert(script.Contains("WatchGlobalStoryIntelligenceProgress", StringComparison.Ordinal), "SignalR updates should refresh aggregate progress instead of trusting chapter totals.");
|
||||||
|
Assert(!script.Contains("progress.totalDetectedScenes", StringComparison.Ordinal), "Client should not use per-run TotalDetectedScenes as the denominator.");
|
||||||
|
Assert(migration.Contains("CanonicalSceneScope", StringComparison.Ordinal), "Progress migration should derive expected scope from canonical scenes.");
|
||||||
|
Assert(migration.Contains("ActiveScenes", StringComparison.Ordinal), "Progress migration should keep active scene count distinct.");
|
||||||
|
Assert(migration.Contains("N'Finalising'", StringComparison.Ordinal), "Progress migration should expose a finalising state.");
|
||||||
|
}
|
||||||
|
|
||||||
static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume()
|
static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume()
|
||||||
{
|
{
|
||||||
var root = PlotLineRoot();
|
var root = PlotLineRoot();
|
||||||
|
|||||||
@ -13,7 +13,8 @@ namespace PlotLine.Hubs;
|
|||||||
public sealed class StoryIntelligenceHub(
|
public sealed class StoryIntelligenceHub(
|
||||||
IStoryIntelligenceService storyIntelligence,
|
IStoryIntelligenceService storyIntelligence,
|
||||||
IStoryIntelligenceResultRepository persistedRuns,
|
IStoryIntelligenceResultRepository persistedRuns,
|
||||||
IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub
|
IOnboardingStoryIntelligenceService onboardingStoryIntelligence,
|
||||||
|
IStoryIntelligenceGlobalProgressService globalProgress) : Hub
|
||||||
{
|
{
|
||||||
public override async Task OnConnectedAsync()
|
public override async Task OnConnectedAsync()
|
||||||
{
|
{
|
||||||
@ -58,6 +59,13 @@ public sealed class StoryIntelligenceHub(
|
|||||||
return await onboardingStoryIntelligence.GetProgressAsync(batchId);
|
return await onboardingStoryIntelligence.GetProgressAsync(batchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceGlobalProgressViewModel> WatchGlobalStoryIntelligenceProgress()
|
||||||
|
{
|
||||||
|
var userId = RequireUserId();
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
||||||
|
return await globalProgress.GetForCurrentUserAsync();
|
||||||
|
}
|
||||||
|
|
||||||
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, Scene? currentScene, string? latestSceneSummary)
|
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary)
|
||||||
|
|||||||
@ -354,9 +354,13 @@ public sealed class StoryIntelligenceActiveBookRunSummary
|
|||||||
public int RunningRunCount { get; init; }
|
public int RunningRunCount { get; init; }
|
||||||
public int CompletedRunCount { get; init; }
|
public int CompletedRunCount { get; init; }
|
||||||
public int FailedRunCount { get; init; }
|
public int FailedRunCount { get; init; }
|
||||||
|
public int TotalExpectedScenes { get; init; }
|
||||||
public int TotalDetectedScenes { get; init; }
|
public int TotalDetectedScenes { get; init; }
|
||||||
public int CompletedScenes { get; init; }
|
public int CompletedScenes { get; init; }
|
||||||
public int FailedScenes { get; init; }
|
public int FailedScenes { get; init; }
|
||||||
|
public int ActiveScenes { get; init; }
|
||||||
|
public int ProgressPercent { get; init; }
|
||||||
|
public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending;
|
||||||
public long TotalDurationMs { get; init; }
|
public long TotalDurationMs { get; init; }
|
||||||
public DateTime FirstCreatedUtc { get; init; }
|
public DateTime FirstCreatedUtc { get; init; }
|
||||||
public DateTime UpdatedUtc { get; init; }
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
|||||||
@ -32,9 +32,11 @@ public sealed class StoryIntelligenceGlobalProgressService(
|
|||||||
RunningRunCount = summary.RunningRunCount,
|
RunningRunCount = summary.RunningRunCount,
|
||||||
CompletedRunCount = summary.CompletedRunCount,
|
CompletedRunCount = summary.CompletedRunCount,
|
||||||
FailedRunCount = summary.FailedRunCount,
|
FailedRunCount = summary.FailedRunCount,
|
||||||
TotalDetectedScenes = summary.TotalDetectedScenes,
|
TotalExpectedScenes = summary.TotalExpectedScenes,
|
||||||
CompletedScenes = summary.CompletedScenes,
|
CompletedScenes = summary.CompletedScenes,
|
||||||
FailedScenes = summary.FailedScenes,
|
FailedScenes = summary.FailedScenes,
|
||||||
|
ActiveScenes = summary.ActiveScenes,
|
||||||
|
Status = summary.Status,
|
||||||
TotalDurationMs = summary.TotalDurationMs,
|
TotalDurationMs = summary.TotalDurationMs,
|
||||||
UpdatedUtc = summary.UpdatedUtc
|
UpdatedUtc = summary.UpdatedUtc
|
||||||
})
|
})
|
||||||
|
|||||||
128
PlotLine/Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql
Normal file
128
PlotLine/Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser
|
||||||
|
@UserID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
;WITH ActiveScopes AS
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
r.UserID,
|
||||||
|
r.ProjectID,
|
||||||
|
r.BookID,
|
||||||
|
LatestCreatedUtc = MAX(r.CreatedUtc)
|
||||||
|
FROM dbo.StoryIntelligenceRuns r
|
||||||
|
WHERE r.UserID = @UserID
|
||||||
|
AND r.Status IN (N'Pending', N'Running')
|
||||||
|
GROUP BY r.UserID, r.ProjectID, r.BookID
|
||||||
|
),
|
||||||
|
ScopedRuns AS
|
||||||
|
(
|
||||||
|
SELECT r.*
|
||||||
|
FROM dbo.StoryIntelligenceRuns r
|
||||||
|
INNER JOIN ActiveScopes scope
|
||||||
|
ON scope.UserID = r.UserID
|
||||||
|
AND ISNULL(scope.ProjectID, -1) = ISNULL(r.ProjectID, -1)
|
||||||
|
AND ISNULL(scope.BookID, -1) = ISNULL(r.BookID, -1)
|
||||||
|
AND r.CreatedUtc >= DATEADD(minute, -30, scope.LatestCreatedUtc)
|
||||||
|
WHERE r.Status IN (N'Pending', N'Running', N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled')
|
||||||
|
),
|
||||||
|
CanonicalSceneScope AS
|
||||||
|
(
|
||||||
|
SELECT DISTINCT
|
||||||
|
r.UserID,
|
||||||
|
r.ProjectID,
|
||||||
|
r.BookID,
|
||||||
|
s.SceneID
|
||||||
|
FROM ScopedRuns r
|
||||||
|
INNER JOIN dbo.Chapters c
|
||||||
|
ON c.ChapterID = r.ChapterID
|
||||||
|
AND c.IsArchived = 0
|
||||||
|
INNER JOIN dbo.Scenes s
|
||||||
|
ON s.ChapterID = c.ChapterID
|
||||||
|
AND s.IsArchived = 0
|
||||||
|
),
|
||||||
|
RunSummary AS
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
r.UserID,
|
||||||
|
r.ProjectID,
|
||||||
|
r.BookID,
|
||||||
|
COUNT(1) AS RunCount,
|
||||||
|
SUM(CASE WHEN r.Status = N'Pending' THEN 1 ELSE 0 END) AS PendingRunCount,
|
||||||
|
SUM(CASE WHEN r.Status = N'Running' THEN 1 ELSE 0 END) AS RunningRunCount,
|
||||||
|
SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount,
|
||||||
|
SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount,
|
||||||
|
SUM(ISNULL(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes,
|
||||||
|
SUM(ISNULL(r.CompletedScenes, 0)) AS CompletedScenes,
|
||||||
|
SUM(ISNULL(r.FailedScenes, 0)) AS FailedScenes,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN r.Status = N'Running'
|
||||||
|
THEN CASE
|
||||||
|
WHEN ISNULL(r.TotalDetectedScenes, 0) - ISNULL(r.CompletedScenes, 0) - ISNULL(r.FailedScenes, 0) > 0
|
||||||
|
THEN ISNULL(r.TotalDetectedScenes, 0) - ISNULL(r.CompletedScenes, 0) - ISNULL(r.FailedScenes, 0)
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
ELSE 0
|
||||||
|
END) AS ActiveScenes,
|
||||||
|
SUM(ISNULL(r.TotalDurationMs, 0)) AS TotalDurationMs,
|
||||||
|
MIN(r.CreatedUtc) AS FirstCreatedUtc,
|
||||||
|
MAX(r.UpdatedUtc) AS UpdatedUtc
|
||||||
|
FROM ScopedRuns r
|
||||||
|
GROUP BY r.UserID, r.ProjectID, r.BookID
|
||||||
|
),
|
||||||
|
ExpectedSceneSummary AS
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
scope.UserID,
|
||||||
|
scope.ProjectID,
|
||||||
|
scope.BookID,
|
||||||
|
COUNT(scope.SceneID) AS TotalExpectedScenes
|
||||||
|
FROM CanonicalSceneScope scope
|
||||||
|
GROUP BY scope.UserID, scope.ProjectID, scope.BookID
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
summary.UserID,
|
||||||
|
summary.ProjectID,
|
||||||
|
p.ProjectName AS ProjectTitle,
|
||||||
|
summary.BookID,
|
||||||
|
b.BookTitle,
|
||||||
|
b.Subtitle AS BookSubtitle,
|
||||||
|
summary.RunCount,
|
||||||
|
summary.PendingRunCount,
|
||||||
|
summary.RunningRunCount,
|
||||||
|
summary.CompletedRunCount,
|
||||||
|
summary.FailedRunCount,
|
||||||
|
COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) AS TotalExpectedScenes,
|
||||||
|
summary.TotalDetectedScenes,
|
||||||
|
summary.CompletedScenes,
|
||||||
|
summary.FailedScenes,
|
||||||
|
summary.ActiveScenes,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) <= 0 THEN 0
|
||||||
|
ELSE CONVERT(int, ROUND(summary.CompletedScenes * 100.0 / COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 1), 0))
|
||||||
|
END AS ProgressPercent,
|
||||||
|
CASE
|
||||||
|
WHEN summary.RunningRunCount > 0
|
||||||
|
AND COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) > 0
|
||||||
|
AND summary.CompletedScenes + summary.FailedScenes >= COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0)
|
||||||
|
THEN N'Finalising'
|
||||||
|
WHEN summary.FailedRunCount > 0 OR summary.FailedScenes > 0 THEN N'CompletedWithWarnings'
|
||||||
|
WHEN summary.RunningRunCount > 0 THEN N'Running'
|
||||||
|
WHEN summary.PendingRunCount > 0 THEN N'Pending'
|
||||||
|
ELSE N'Completed'
|
||||||
|
END AS Status,
|
||||||
|
summary.TotalDurationMs,
|
||||||
|
summary.FirstCreatedUtc,
|
||||||
|
summary.UpdatedUtc
|
||||||
|
FROM RunSummary summary
|
||||||
|
LEFT JOIN ExpectedSceneSummary expected
|
||||||
|
ON expected.UserID = summary.UserID
|
||||||
|
AND ISNULL(expected.ProjectID, -1) = ISNULL(summary.ProjectID, -1)
|
||||||
|
AND ISNULL(expected.BookID, -1) = ISNULL(summary.BookID, -1)
|
||||||
|
LEFT JOIN dbo.Projects p ON p.ProjectID = summary.ProjectID
|
||||||
|
LEFT JOIN dbo.Books b ON b.BookID = summary.BookID
|
||||||
|
WHERE summary.PendingRunCount > 0 OR summary.RunningRunCount > 0
|
||||||
|
ORDER BY summary.UpdatedUtc DESC, summary.FirstCreatedUtc;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
using PlotLine.Models;
|
||||||
|
|
||||||
namespace PlotLine.ViewModels;
|
namespace PlotLine.ViewModels;
|
||||||
|
|
||||||
public sealed class StoryIntelligenceGlobalProgressViewModel
|
public sealed class StoryIntelligenceGlobalProgressViewModel
|
||||||
@ -6,12 +8,21 @@ public sealed class StoryIntelligenceGlobalProgressViewModel
|
|||||||
public bool HasActiveWork => Books.Any(book => book.HasActiveRuns);
|
public bool HasActiveWork => Books.Any(book => book.HasActiveRuns);
|
||||||
public int ActiveBookCount => Books.Count(book => book.HasActiveRuns);
|
public int ActiveBookCount => Books.Count(book => book.HasActiveRuns);
|
||||||
public int ActiveRunCount => Books.Sum(book => book.PendingRunCount + book.RunningRunCount);
|
public int ActiveRunCount => Books.Sum(book => book.PendingRunCount + book.RunningRunCount);
|
||||||
public int TotalDetectedScenes => Books.Sum(book => book.TotalDetectedScenes);
|
public int TotalExpectedScenes => Books.Sum(book => book.TotalExpectedScenes);
|
||||||
|
public int TotalDetectedScenes => TotalExpectedScenes;
|
||||||
public int CompletedScenes => Books.Sum(book => book.CompletedScenes);
|
public int CompletedScenes => Books.Sum(book => book.CompletedScenes);
|
||||||
public int FailedScenes => Books.Sum(book => book.FailedScenes);
|
public int FailedScenes => Books.Sum(book => book.FailedScenes);
|
||||||
public int ProgressPercent => TotalDetectedScenes <= 0
|
public int ActiveScenes => Books.Sum(book => book.ActiveScenes);
|
||||||
|
public int ProgressPercent => TotalExpectedScenes <= 0
|
||||||
? 0
|
? 0
|
||||||
: Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100);
|
: Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalExpectedScenes)), 0, 100);
|
||||||
|
public string Status => Books.Any(book => book.Status == "Finalising")
|
||||||
|
? "Finalising"
|
||||||
|
: Books.Any(book => book.HasFailures)
|
||||||
|
? StoryIntelligenceRunStatuses.CompletedWithWarnings
|
||||||
|
: Books.Any(book => book.HasActiveRuns)
|
||||||
|
? StoryIntelligenceRunStatuses.Running
|
||||||
|
: StoryIntelligenceRunStatuses.Completed;
|
||||||
public string SummaryText => ActiveBookCount switch
|
public string SummaryText => ActiveBookCount switch
|
||||||
{
|
{
|
||||||
0 => string.Empty,
|
0 => string.Empty,
|
||||||
@ -30,34 +41,40 @@ public sealed class StoryIntelligenceGlobalProgressBookViewModel
|
|||||||
public int RunningRunCount { get; init; }
|
public int RunningRunCount { get; init; }
|
||||||
public int CompletedRunCount { get; init; }
|
public int CompletedRunCount { get; init; }
|
||||||
public int FailedRunCount { get; init; }
|
public int FailedRunCount { get; init; }
|
||||||
public int TotalDetectedScenes { get; init; }
|
public int TotalExpectedScenes { get; init; }
|
||||||
|
public int TotalDetectedScenes => TotalExpectedScenes;
|
||||||
public int CompletedScenes { get; init; }
|
public int CompletedScenes { get; init; }
|
||||||
public int FailedScenes { get; init; }
|
public int FailedScenes { get; init; }
|
||||||
|
public int ActiveScenes { get; init; }
|
||||||
|
public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending;
|
||||||
public long TotalDurationMs { get; init; }
|
public long TotalDurationMs { get; init; }
|
||||||
public DateTime UpdatedUtc { get; init; }
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
|
||||||
public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0;
|
public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0;
|
||||||
public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0;
|
public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0;
|
||||||
public int ProgressPercent => TotalDetectedScenes <= 0
|
public int ProgressPercent => TotalExpectedScenes <= 0
|
||||||
? 0
|
? 0
|
||||||
: Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100);
|
: Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalExpectedScenes)), 0, 100);
|
||||||
public string SummaryText => $"{BookTitle}: {CompletedScenes:N0}/{Math.Max(TotalDetectedScenes, CompletedScenes):N0} scenes";
|
public string SummaryText => $"{BookTitle}: {CompletedScenes:N0}/{Math.Max(TotalExpectedScenes, CompletedScenes):N0} scenes";
|
||||||
public string EstimatedRemaining => EstimateRemaining(TotalDurationMs, CompletedScenes, TotalDetectedScenes);
|
public string EstimatedRemaining => Status == "Finalising"
|
||||||
|
? "Finalising"
|
||||||
|
: EstimateRemaining(TotalDurationMs, CompletedScenes, FailedScenes, TotalExpectedScenes);
|
||||||
|
|
||||||
private static string EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes)
|
private static string EstimateRemaining(long elapsedMs, int completedScenes, int failedScenes, int totalScenes)
|
||||||
{
|
{
|
||||||
if (totalScenes <= 0 || completedScenes <= 0)
|
if (totalScenes <= 0 || completedScenes <= 0)
|
||||||
{
|
{
|
||||||
return "Calculating";
|
return "Calculating";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completedScenes >= totalScenes)
|
var remainingScenes = Math.Max(0, totalScenes - completedScenes - failedScenes);
|
||||||
|
if (remainingScenes == 0)
|
||||||
{
|
{
|
||||||
return "Finalising";
|
return "Finalising";
|
||||||
}
|
}
|
||||||
|
|
||||||
var averageSceneMs = elapsedMs / Math.Max(1, completedScenes);
|
var averageSceneMs = elapsedMs / Math.Max(1, completedScenes);
|
||||||
var remaining = TimeSpan.FromMilliseconds(averageSceneMs * (totalScenes - completedScenes));
|
var remaining = TimeSpan.FromMilliseconds(averageSceneMs * remainingScenes);
|
||||||
if (remaining.TotalMinutes >= 1)
|
if (remaining.TotalMinutes >= 1)
|
||||||
{
|
{
|
||||||
return $"{Math.Max(1, (int)Math.Round(remaining.TotalMinutes)):N0} min";
|
return $"{Math.Max(1, (int)Math.Round(remaining.TotalMinutes)):N0} min";
|
||||||
|
|||||||
@ -234,6 +234,7 @@
|
|||||||
<div class="story-intelligence-global-strip"
|
<div class="story-intelligence-global-strip"
|
||||||
data-story-intelligence-global-strip
|
data-story-intelligence-global-strip
|
||||||
data-progress-percent="@storyIntelligenceProgress.ProgressPercent"
|
data-progress-percent="@storyIntelligenceProgress.ProgressPercent"
|
||||||
|
data-total-expected-scenes="@storyIntelligenceProgress.TotalExpectedScenes"
|
||||||
data-active-books="@storyIntelligenceProgress.ActiveBookCount"
|
data-active-books="@storyIntelligenceProgress.ActiveBookCount"
|
||||||
data-book-id="@primaryBook?.BookID"
|
data-book-id="@primaryBook?.BookID"
|
||||||
data-project-id="@primaryBook?.ProjectID"
|
data-project-id="@primaryBook?.ProjectID"
|
||||||
@ -244,7 +245,11 @@
|
|||||||
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>
|
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>
|
||||||
<strong data-story-intelligence-global-title>@storyIntelligenceProgress.SummaryText</strong>
|
<strong data-story-intelligence-global-title>@storyIntelligenceProgress.SummaryText</strong>
|
||||||
<span data-story-intelligence-global-detail>
|
<span data-story-intelligence-global-detail>
|
||||||
@storyIntelligenceProgress.CompletedScenes.ToString("N0") of @storyIntelligenceProgress.TotalDetectedScenes.ToString("N0") scenes analysed
|
@storyIntelligenceProgress.CompletedScenes.ToString("N0") of @storyIntelligenceProgress.TotalExpectedScenes.ToString("N0") scenes analysed
|
||||||
|
@if (storyIntelligenceProgress.FailedScenes > 0)
|
||||||
|
{
|
||||||
|
<text> · @storyIntelligenceProgress.FailedScenes.ToString("N0") failed</text>
|
||||||
|
}
|
||||||
@if (primaryBook is not null)
|
@if (primaryBook is not null)
|
||||||
{
|
{
|
||||||
<text> · @primaryBook.EstimatedRemaining remaining</text>
|
<text> · @primaryBook.EstimatedRemaining remaining</text>
|
||||||
|
|||||||
@ -7,24 +7,28 @@
|
|||||||
const title = strip.querySelector("[data-story-intelligence-global-title]");
|
const title = strip.querySelector("[data-story-intelligence-global-title]");
|
||||||
const detail = strip.querySelector("[data-story-intelligence-global-detail]");
|
const detail = strip.querySelector("[data-story-intelligence-global-detail]");
|
||||||
const bar = strip.querySelector("[data-story-intelligence-global-bar]");
|
const bar = strip.querySelector("[data-story-intelligence-global-bar]");
|
||||||
|
let refreshTimer = null;
|
||||||
const readNumber = (value) => {
|
const readNumber = (value) => {
|
||||||
const parsed = Number.parseInt(value || "", 10);
|
const parsed = Number.parseInt(value || "", 10);
|
||||||
return Number.isInteger(parsed) ? parsed : null;
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const trackedBookId = readNumber(strip.dataset.bookId);
|
|
||||||
const trackedProjectId = readNumber(strip.dataset.projectId);
|
|
||||||
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
|
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
|
||||||
const estimateRemaining = (elapsedMs, completed, total) => {
|
const estimateRemaining = (elapsedMs, completed, failed, total, status) => {
|
||||||
|
if (status === "Finalising") {
|
||||||
|
return "Finalising";
|
||||||
|
}
|
||||||
|
|
||||||
if (!total || !completed) {
|
if (!total || !completed) {
|
||||||
return "Calculating";
|
return "Calculating";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completed >= total) {
|
const remainingScenes = Math.max(0, total - completed - failed);
|
||||||
|
if (remainingScenes === 0) {
|
||||||
return "Finalising";
|
return "Finalising";
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingMs = (elapsedMs / completed) * (total - completed);
|
const remainingMs = (elapsedMs / completed) * remainingScenes;
|
||||||
const remainingSeconds = Math.max(1, Math.round(remainingMs / 1000));
|
const remainingSeconds = Math.max(1, Math.round(remainingMs / 1000));
|
||||||
if (remainingSeconds >= 60) {
|
if (remainingSeconds >= 60) {
|
||||||
return `${Math.max(1, Math.round(remainingSeconds / 60))} min`;
|
return `${Math.max(1, Math.round(remainingSeconds / 60))} min`;
|
||||||
@ -33,26 +37,22 @@
|
|||||||
return `${remainingSeconds} sec`;
|
return `${remainingSeconds} sec`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const update = (progress) => {
|
const render = (progress) => {
|
||||||
if (!progress) {
|
const books = progress?.books || progress?.Books || [];
|
||||||
|
if (!books.length) {
|
||||||
|
strip.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookId = readNumber(progress.bookID ?? progress.bookId);
|
strip.hidden = false;
|
||||||
const projectId = readNumber(progress.projectID ?? progress.projectId);
|
const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0];
|
||||||
if (trackedBookId && bookId && trackedBookId !== bookId) {
|
const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length;
|
||||||
return;
|
const status = progress.status || progress.Status || primary.status || primary.Status || "";
|
||||||
}
|
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0;
|
||||||
|
|
||||||
if (!trackedBookId && trackedProjectId && projectId && trackedProjectId !== projectId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = progress.status || progress.Status || "";
|
|
||||||
const total = readNumber(progress.totalDetectedScenes ?? progress.TotalDetectedScenes) || 0;
|
|
||||||
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0;
|
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0;
|
||||||
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0;
|
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0;
|
||||||
const elapsedMs = readNumber(progress.totalDurationMs ?? progress.TotalDurationMs) || 0;
|
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) || 0;
|
||||||
|
const elapsedMs = readNumber(primary.totalDurationMs ?? primary.TotalDurationMs) || 0;
|
||||||
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
|
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
|
||||||
|
|
||||||
if (bar) {
|
if (bar) {
|
||||||
@ -60,12 +60,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (detail) {
|
if (detail) {
|
||||||
const remaining = estimateRemaining(elapsedMs, completed, total);
|
const remaining = estimateRemaining(elapsedMs, completed, failed, total, status);
|
||||||
detail.textContent = `${formatCount(completed)} of ${formatCount(total)} scenes analysed \u00b7 ${remaining} remaining`;
|
const failureText = failed > 0 ? ` \u00b7 ${formatCount(failed)} failed` : "";
|
||||||
|
const activeText = active > 0 ? ` \u00b7 ${formatCount(active)} active` : "";
|
||||||
|
detail.textContent = `${formatCount(completed)} of ${formatCount(total)} scenes analysed${failureText}${activeText} \u00b7 ${remaining} remaining`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (title && status) {
|
if (title && status) {
|
||||||
if (status === "Failed") {
|
if (status === "Finalising") {
|
||||||
|
title.textContent = "Finalising Story Intelligence";
|
||||||
|
} else if (status === "Failed") {
|
||||||
title.textContent = "Story Intelligence needs attention";
|
title.textContent = "Story Intelligence needs attention";
|
||||||
} else if (status === "Cancelled") {
|
} else if (status === "Cancelled") {
|
||||||
title.textContent = "Story Intelligence cancelled";
|
title.textContent = "Story Intelligence cancelled";
|
||||||
@ -74,17 +78,36 @@
|
|||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
strip.hidden = true;
|
strip.hidden = true;
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
} else if (activeBookCount > 1) {
|
||||||
|
title.textContent = `${formatCount(activeBookCount)} books analysing`;
|
||||||
|
} else {
|
||||||
|
title.textContent = primary.summaryText || primary.SummaryText || title.textContent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
connection.invoke("WatchGlobalStoryIntelligenceProgress")
|
||||||
|
.then(render)
|
||||||
|
.catch(() => {
|
||||||
|
strip.dataset.connection = "offline";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRefresh = () => {
|
||||||
|
window.clearTimeout(refreshTimer);
|
||||||
|
refreshTimer = window.setTimeout(refresh, 350);
|
||||||
|
};
|
||||||
|
|
||||||
const connection = new signalR.HubConnectionBuilder()
|
const connection = new signalR.HubConnectionBuilder()
|
||||||
.withUrl("/hubs/story-intelligence")
|
.withUrl("/hubs/story-intelligence")
|
||||||
.withAutomaticReconnect()
|
.withAutomaticReconnect()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
connection.on("StoryIntelligenceRunProgressChanged", update);
|
connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh);
|
||||||
connection.start().catch(() => {
|
connection.start()
|
||||||
strip.dataset.connection = "offline";
|
.then(refresh)
|
||||||
});
|
.catch(() => {
|
||||||
|
strip.dataset.connection = "offline";
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user