200 lines
7.1 KiB
C#
200 lines
7.1 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) : 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 sceneResults = await persistedRuns.ListSceneResultsAsync(runId);
|
|
return ToRunProgress(run, "Current status", LatestSceneSummary(sceneResults));
|
|
}
|
|
|
|
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
|
|
{
|
|
var userId = RequireUserId();
|
|
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
|
return await onboardingStoryIntelligence.GetProgressAsync(batchId);
|
|
}
|
|
|
|
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
|
|
|
|
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, 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));
|
|
|
|
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,
|
|
CurrentSceneNumber = run.CompletedScenes,
|
|
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 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);
|
|
}
|