PlotDirector/PlotLine/Services/StoryIntelligenceService.cs

278 lines
10 KiB
C#

using Microsoft.AspNetCore.SignalR;
using PlotLine.Data;
using PlotLine.Hubs;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceService
{
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid jobId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid jobId);
Task<StoryIntelligenceJob?> StartAsync();
Task<StoryIntelligenceJob?> CancelAsync(Guid jobId);
Task<StoryIntelligenceDashboardViewModel> GetDashboardAsync();
Task<StoryIntelligenceJobProgress?> GetProgressForUserAsync(int userId, Guid jobId);
}
public interface IStoryIntelligenceProvider
{
Task ProcessAsync(StoryIntelligenceJob job, Func<StoryIntelligenceProviderProgress, Task> progress, CancellationToken cancellationToken);
}
public sealed class StoryIntelligenceProviderProgress
{
public string Status { get; init; } = StoryIntelligenceJobStatuses.Preparing;
public string Stage { get; init; } = StoryIntelligenceJobStatuses.Preparing;
public string Message { get; init; } = "Preparing manuscript...";
public int Percent { get; init; }
public string? EstimatedRemaining { get; init; }
}
public sealed class StubStoryIntelligenceProvider : IStoryIntelligenceProvider
{
public async Task ProcessAsync(StoryIntelligenceJob job, Func<StoryIntelligenceProviderProgress, Task> progress, CancellationToken cancellationToken)
{
var stages = new[]
{
new StoryIntelligenceProviderProgress
{
Percent = 20,
Message = "Building analysis pipeline...",
EstimatedRemaining = "Calculating"
},
new StoryIntelligenceProviderProgress
{
Percent = 55,
Message = "Preparing scene batches...",
EstimatedRemaining = "Calculating"
},
new StoryIntelligenceProviderProgress
{
Percent = 85,
Message = "Initialising Story Intelligence...",
EstimatedRemaining = "Calculating"
}
};
foreach (var stage in stages)
{
cancellationToken.ThrowIfCancellationRequested();
await progress(stage);
await Task.Yield();
}
}
}
public sealed class StoryIntelligenceService(
IStoryIntelligenceRepository repository,
IOnboardingRepository onboarding,
IProjectRepository projects,
IBookRepository books,
ICurrentUserService currentUser) : IStoryIntelligenceService
{
private const string ConsentText = "I consent to PlotDirector preparing the Story Intelligence analysis framework for this manuscript. I understand future AI analysis is optional and will use the OpenAI API.";
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
{
var state = await onboarding.GetOrCreateAsync(RequireUserId());
var latest = await repository.GetLatestForUserAsync(state.UserID);
return new StoryIntelligenceOverviewViewModel
{
ProjectID = state.ProjectID,
BookID = state.BookID,
SelectedProjectName = state.ProjectID.HasValue ? (await projects.GetForUserAsync(state.ProjectID.Value, state.UserID))?.ProjectName : null,
SelectedBookTitle = state.BookID.HasValue ? BuildBookTitle(await books.GetAsync(state.BookID.Value)) : null,
ExistingJob = latest is null ? null : ToProgress(latest)
};
}
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid jobId)
{
var job = await repository.GetAsync(RequireUserId(), jobId);
return job is null
? null
: new StoryIntelligenceProgressViewModel
{
Job = ToProgress(job),
ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName,
BookTitle = BuildBookTitle(await books.GetAsync(job.BookID))
};
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid jobId)
{
var job = await repository.GetAsync(RequireUserId(), jobId);
if (job is null || !job.IsCompleted)
{
return null;
}
return new StoryIntelligenceCompletionViewModel
{
Job = ToProgress(job),
ProjectID = job.ProjectID,
BookID = job.BookID,
ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName,
BookTitle = BuildBookTitle(await books.GetAsync(job.BookID))
};
}
public async Task<StoryIntelligenceJob?> StartAsync()
{
var state = await onboarding.GetOrCreateAsync(RequireUserId());
if (!state.ProjectID.HasValue || !state.BookID.HasValue)
{
return null;
}
return await repository.CreateWithConsentAsync(
state.UserID,
state.UserOnboardingStateID,
state.ProjectID.Value,
state.BookID.Value,
ConsentText);
}
public async Task<StoryIntelligenceJob?> CancelAsync(Guid jobId)
=> await repository.CancelAsync(RequireUserId(), jobId);
public async Task<StoryIntelligenceDashboardViewModel> GetDashboardAsync()
{
if (!currentUser.UserId.HasValue)
{
return new StoryIntelligenceDashboardViewModel();
}
var state = await onboarding.GetAsync(currentUser.UserId.Value);
return state?.ProjectID.HasValue == true && state.BookID.HasValue
? new StoryIntelligenceDashboardViewModel
{
ShouldShow = true,
Title = "Import your manuscript",
Description = "Scan your manuscript with the Word Companion, review the chapters, then let PlotDirector prepare scene suggestions.",
ButtonText = "Continue manuscript import"
}
: new StoryIntelligenceDashboardViewModel();
}
public async Task<StoryIntelligenceJobProgress?> GetProgressForUserAsync(int userId, Guid jobId)
{
var job = await repository.GetAsync(userId, jobId);
return job is null ? null : ToProgress(job);
}
public static StoryIntelligenceJobProgress ToProgress(StoryIntelligenceJob job)
{
var elapsedFrom = job.StartedUtc ?? job.CreatedUtc;
return new StoryIntelligenceJobProgress
{
JobID = job.JobID,
UserID = job.UserID,
ProjectID = job.ProjectID,
BookID = job.BookID,
Status = job.Status,
CurrentStage = job.CurrentStage,
CurrentMessage = job.CurrentMessage,
ProgressPercent = job.ProgressPercent,
EstimatedRemaining = job.EstimatedRemaining,
ErrorMessage = job.ErrorMessage,
UpdatedUtc = job.UpdatedUtc,
IsActive = job.IsActive,
IsCompleted = job.IsCompleted,
ElapsedTime = FormatElapsed(DateTime.UtcNow - elapsedFrom)
};
}
private int RequireUserId()
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in to use Story Intelligence.");
private static string BuildBookTitle(Book? book)
=> book is null ? "Selected book" : $"Book {book.BookNumber}: {book.BookDisplayTitle}";
private static string FormatElapsed(TimeSpan elapsed)
=> elapsed.TotalMinutes < 1
? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec"
: $"{(int)elapsed.TotalMinutes} min {elapsed.Seconds:D2} sec";
}
public sealed class StoryIntelligenceWorker(
IServiceScopeFactory scopeFactory,
IHubContext<StoryIntelligenceHub> hub,
ILogger<StoryIntelligenceWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
while (!stoppingToken.IsCancellationRequested)
{
await ProcessNextAsync(stoppingToken);
await timer.WaitForNextTickAsync(stoppingToken);
}
}
private async Task ProcessNextAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceRepository>();
var provider = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceProvider>();
var job = await repository.ClaimNextPendingAsync();
if (job is null)
{
return;
}
await NotifyAsync(job);
try
{
await provider.ProcessAsync(job, async update =>
{
var updated = await repository.UpdateProgressAsync(
job.JobID,
update.Status,
update.Percent,
update.Stage,
update.Message,
update.EstimatedRemaining);
if (updated is not null)
{
await NotifyAsync(updated);
if (updated.IsCancelled)
{
throw new OperationCanceledException();
}
}
}, stoppingToken);
var completed = await repository.CompleteAsync(job.JobID);
if (completed is not null)
{
await NotifyAsync(completed);
}
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
var cancelled = await repository.CancelAsync(job.UserID, job.JobID);
if (cancelled is not null)
{
await NotifyAsync(cancelled);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Story Intelligence job {JobID} failed.", job.JobID);
var failed = await repository.FailAsync(job.JobID, ex.Message);
if (failed is not null)
{
await NotifyAsync(failed);
}
}
}
private Task NotifyAsync(StoryIntelligenceJob job)
=> hub.Clients.Group(StoryIntelligenceHub.UserGroup(job.UserID))
.SendAsync("StoryIntelligenceProgressChanged", StoryIntelligenceService.ToProgress(job));
}