PlotDirector/PlotLine/Hubs/StoryIntelligenceHub.cs

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);
}