269 lines
10 KiB
C#
269 lines
10 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
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,
|
|
IStoryIntelligenceResultRepository persistedRuns,
|
|
IOnboardingStoryIntelligenceService onboardingStoryIntelligence,
|
|
IStoryIntelligenceGlobalProgressService globalProgress) : Hub
|
|
{
|
|
public override async Task OnConnectedAsync()
|
|
{
|
|
if (TryGetUserId(out var userId))
|
|
{
|
|
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
|
}
|
|
|
|
await base.OnConnectedAsync();
|
|
}
|
|
|
|
public async Task<StoryIntelligenceJobProgress?> WatchStoryIntelligenceJob(Guid jobId)
|
|
{
|
|
var userId = RequireUserId();
|
|
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
|
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);
|
|
if (run is null || run.UserID != userId)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var canonicalScenes = run.ChapterID.HasValue
|
|
? await persistedRuns.ListChapterScenesAsync(run.ChapterID.Value)
|
|
: [];
|
|
var currentScene = CurrentCanonicalScene(run, canonicalScenes);
|
|
var sceneResults = currentScene is null ? await persistedRuns.ListSceneResultsAsync(runId) : [];
|
|
return ToRunProgress(run, "Current status", currentScene, currentScene is null ? LatestSceneSummary(sceneResults) : CanonicalSceneSummary(currentScene));
|
|
}
|
|
|
|
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
|
|
{
|
|
var userId = RequireUserId();
|
|
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
|
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}";
|
|
|
|
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary)
|
|
{
|
|
var completedScenes = run.CompletedScenes ?? 0;
|
|
var totalScenes = run.TotalDetectedScenes ?? 0;
|
|
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
|
|
var currentSceneNumber = currentScene is null
|
|
? CurrentSceneNumber(run)
|
|
: Convert.ToInt32(currentScene.SceneNumber);
|
|
|
|
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,
|
|
CurrentSceneID = currentScene?.SceneID,
|
|
CurrentSceneNumber = currentSceneNumber,
|
|
CurrentSceneDisplayTitle = currentScene is null || !currentSceneNumber.HasValue
|
|
? null
|
|
: SceneTitleDisplay.Format(currentScene.SceneNumber, currentScene.SceneTitle),
|
|
ElapsedTime = FormatDuration(elapsedMs),
|
|
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes),
|
|
LatestSceneSummary = latestSceneSummary,
|
|
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 Scene? CurrentCanonicalScene(StoryIntelligenceSavedRun run, IReadOnlyList<Scene> scenes)
|
|
{
|
|
if (scenes.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var orderedScenes = scenes.OrderBy(scene => scene.SceneNumber).ToList();
|
|
var sceneNumber = CurrentSceneNumber(run);
|
|
if (sceneNumber is not > 0)
|
|
{
|
|
return orderedScenes[0];
|
|
}
|
|
|
|
var byNumber = orderedScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == sceneNumber.Value);
|
|
if (byNumber is not null)
|
|
{
|
|
return byNumber;
|
|
}
|
|
|
|
return orderedScenes[Math.Clamp(sceneNumber.Value - 1, 0, orderedScenes.Count - 1)];
|
|
}
|
|
|
|
private static int? CurrentSceneNumber(StoryIntelligenceSavedRun run)
|
|
{
|
|
var completed = Math.Max(0, run.CompletedScenes ?? 0);
|
|
var total = Math.Max(0, run.TotalDetectedScenes ?? 0);
|
|
if (run.Status == StoryIntelligenceRunStatuses.Running
|
|
&& string.Equals(run.CurrentStage, StoryIntelligenceFailureStages.SceneIntelligence, StringComparison.OrdinalIgnoreCase)
|
|
&& total > 0)
|
|
{
|
|
return Math.Clamp(completed + 1, 1, total);
|
|
}
|
|
|
|
if (completed > 0)
|
|
{
|
|
return Math.Clamp(completed, 1, total > 0 ? total : completed);
|
|
}
|
|
|
|
return run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running && total > 0
|
|
? 1
|
|
: null;
|
|
}
|
|
|
|
private static string? CanonicalSceneSummary(Scene? scene)
|
|
=> FirstConfigured(scene?.StructuralSummary, scene?.Summary);
|
|
|
|
private static string? FirstConfigured(params string?[] values)
|
|
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
|
|
|
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 static string? LatestSceneSummary(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
|
|
{
|
|
foreach (var scene in sceneResults
|
|
.Where(scene => scene.ValidationErrorsCount == 0 && !string.IsNullOrWhiteSpace(scene.ParsedJson))
|
|
.OrderByDescending(scene => scene.TemporarySceneNumber))
|
|
{
|
|
var summary = ExtractShortSummary(scene.ParsedJson);
|
|
if (!string.IsNullOrWhiteSpace(summary))
|
|
{
|
|
return Trim(summary, 220);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? ExtractShortSummary(string json)
|
|
{
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(json);
|
|
var root = document.RootElement;
|
|
if (TryReadShortSummary(root, out var direct))
|
|
{
|
|
return direct;
|
|
}
|
|
|
|
if (root.TryGetProperty("parsedScene", out var parsedScene)
|
|
&& TryReadShortSummary(parsedScene, out var wrapped))
|
|
{
|
|
return wrapped;
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool TryReadShortSummary(JsonElement element, out string? summary)
|
|
{
|
|
summary = null;
|
|
if (element.ValueKind != JsonValueKind.Object
|
|
|| !element.TryGetProperty("summary", out var summaryElement)
|
|
|| summaryElement.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (TryReadSummaryValue(summaryElement, "short", out summary))
|
|
{
|
|
return !string.IsNullOrWhiteSpace(summary);
|
|
}
|
|
|
|
return TryReadSummaryValue(summaryElement, "detailed", out summary)
|
|
&& !string.IsNullOrWhiteSpace(summary);
|
|
}
|
|
|
|
private static bool TryReadSummaryValue(JsonElement summaryElement, string propertyName, out string? value)
|
|
{
|
|
value = null;
|
|
if (summaryElement.TryGetProperty(propertyName, out var textElement)
|
|
&& textElement.ValueKind == JsonValueKind.String)
|
|
{
|
|
value = textElement.GetString();
|
|
return !string.IsNullOrWhiteSpace(value);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string Trim(string value, int maxLength)
|
|
{
|
|
var trimmed = value.Trim();
|
|
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd();
|
|
}
|
|
|
|
private int RequireUserId()
|
|
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence.");
|
|
|
|
private bool TryGetUserId(out int userId)
|
|
=> int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId);
|
|
}
|