383 lines
16 KiB
C#
383 lines
16 KiB
C#
using System.Collections.Concurrent;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IOnboardingStoryIntelligenceService
|
|
{
|
|
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
|
Task<OnboardingStoryIntelligenceBatch?> StartAsync();
|
|
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
|
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
|
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
|
}
|
|
|
|
public sealed class OnboardingStoryIntelligenceService(
|
|
IOnboardingRepository onboardingRepository,
|
|
IOnboardingService onboarding,
|
|
IManuscriptScanPreviewStore scanStore,
|
|
IStoryIntelligenceResultRepository runs,
|
|
IStoryIntelligenceImportCommitService commits,
|
|
IStoryIntelligenceClient client,
|
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
|
ICurrentUserService currentUser,
|
|
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
|
|
{
|
|
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
|
|
private const string SourceType = "OnboardingWordCompanionChapter";
|
|
|
|
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
|
|
{
|
|
var userId = RequireUserId();
|
|
var context = await GetContextAsync(userId);
|
|
if (context is null)
|
|
{
|
|
return new StoryIntelligenceOverviewViewModel
|
|
{
|
|
Message = "Finish the Word Companion scan and review before analysing your manuscript."
|
|
};
|
|
}
|
|
|
|
var (_, preview, review, build, wizard) = context.Value;
|
|
var included = IncludedChapters(preview, review);
|
|
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
|
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
|
|
return new StoryIntelligenceOverviewViewModel
|
|
{
|
|
PreviewID = preview.PreviewID,
|
|
ProjectID = preview.ProjectID,
|
|
BookID = preview.BookID,
|
|
SelectedProjectName = wizard.SelectedProjectName,
|
|
SelectedBookTitle = wizard.SelectedBookTitle,
|
|
ApprovedChapterCount = included.Count,
|
|
ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount),
|
|
MissingChapterTextCount = missingTextCount,
|
|
ExistingBatchID = existingBatch?.BatchID,
|
|
CanStart = included.Count > 0 && missingTextCount == 0,
|
|
Message = build is null
|
|
? "PlotDirector will prepare the approved chapters, then analyse them with Story Intelligence."
|
|
: "The approved chapters are ready to analyse."
|
|
};
|
|
}
|
|
|
|
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync()
|
|
{
|
|
var userId = RequireUserId();
|
|
var context = await GetContextAsync(userId);
|
|
if (context is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var (state, preview, review, build, wizard) = context.Value;
|
|
var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
|
if (existing is not null)
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
build ??= await onboarding.BuildApprovedStructureAsync(preview.PreviewID)
|
|
?? throw new InvalidOperationException("The approved chapters could not be prepared for analysis.");
|
|
|
|
var chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal);
|
|
var included = IncludedChapters(preview, review);
|
|
if (included.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("Choose at least one chapter before analysing the manuscript.");
|
|
}
|
|
|
|
if (included.Any(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)))
|
|
{
|
|
throw new InvalidOperationException("The Word Companion scan did not include chapter text. Scan the manuscript again before analysing it.");
|
|
}
|
|
|
|
var clientStatus = client.GetConfigurationStatus();
|
|
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
|
|
clientStatus.ChapterStructureModel,
|
|
clientStatus.SceneIntelligenceModel);
|
|
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
|
|
|
foreach (var chapter in included)
|
|
{
|
|
if (!chapterIds.TryGetValue(chapter.Preview.TemporaryChapterKey, out var chapterId))
|
|
{
|
|
chapterId = chapter.Preview.ExistingChapterID ?? 0;
|
|
}
|
|
|
|
if (chapterId <= 0)
|
|
{
|
|
throw new InvalidOperationException($"PlotDirector could not prepare chapter '{chapter.Decision.Title}' for analysis.");
|
|
}
|
|
|
|
var text = chapter.Preview.ChapterText!.Trim();
|
|
var paragraphs = StoryIntelligenceParagraphs.Split(text);
|
|
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
|
{
|
|
UserID = userId,
|
|
ProjectID = preview.ProjectID,
|
|
BookID = preview.BookID,
|
|
ChapterID = chapterId,
|
|
ChapterNumber = chapter.Decision.ChapterNumber,
|
|
SourceType = SourceType,
|
|
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
|
|
ChapterText = text,
|
|
SourceFileName = preview.DocumentTitle,
|
|
SourceWordCount = CountWords(text),
|
|
SourceCharacterCount = text.Length,
|
|
SourceParagraphCount = paragraphs.Count,
|
|
SourceChapterCount = 1,
|
|
Model = modelSummary,
|
|
PromptVersionsSummary = PromptVersionsSummary
|
|
});
|
|
|
|
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
|
|
{
|
|
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
|
|
ChapterNumber = chapter.Decision.ChapterNumber,
|
|
ChapterTitle = chapter.Decision.Title,
|
|
ChapterID = chapterId,
|
|
RunID = runId
|
|
});
|
|
}
|
|
|
|
var batch = new OnboardingStoryIntelligenceBatch
|
|
{
|
|
UserID = userId,
|
|
OnboardingID = state.UserOnboardingStateID,
|
|
PreviewID = preview.PreviewID,
|
|
ProjectID = preview.ProjectID,
|
|
BookID = preview.BookID,
|
|
ProjectName = wizard.SelectedProjectName,
|
|
BookTitle = wizard.SelectedBookTitle,
|
|
Items = batchItems
|
|
};
|
|
|
|
await batchStore.SaveAsync(batch);
|
|
logger.LogInformation(
|
|
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
|
|
batch.BatchID,
|
|
userId,
|
|
preview.ProjectID,
|
|
preview.BookID,
|
|
batchItems.Count);
|
|
|
|
return batch;
|
|
}
|
|
|
|
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
|
|
{
|
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
|
if (batch is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var chapters = new List<StoryIntelligenceOnboardingChapterViewModel>();
|
|
foreach (var item in batch.Items)
|
|
{
|
|
var run = await runs.GetRunAsync(item.RunID);
|
|
if (run is null)
|
|
{
|
|
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
|
|
{
|
|
TemporaryChapterKey = item.TemporaryChapterKey,
|
|
ChapterNumber = item.ChapterNumber,
|
|
ChapterTitle = item.ChapterTitle,
|
|
ChapterID = item.ChapterID,
|
|
RunID = item.RunID,
|
|
Status = "Missing",
|
|
ErrorMessage = "This analysis run could not be found."
|
|
});
|
|
continue;
|
|
}
|
|
|
|
var commit = await runs.GetImportCommitAsync(item.RunID);
|
|
var confirmation = run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
|
|
? await commits.BuildConfirmationAsync(item.RunID)
|
|
: null;
|
|
|
|
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
|
|
{
|
|
TemporaryChapterKey = item.TemporaryChapterKey,
|
|
ChapterNumber = item.ChapterNumber,
|
|
ChapterTitle = item.ChapterTitle,
|
|
ChapterID = item.ChapterID,
|
|
RunID = item.RunID,
|
|
Status = run.Status,
|
|
CurrentStage = run.CurrentStage,
|
|
CurrentMessage = run.CurrentMessage,
|
|
TotalDetectedScenes = run.TotalDetectedScenes,
|
|
CompletedScenes = run.CompletedScenes,
|
|
FailedScenes = run.FailedScenes,
|
|
TotalTokens = run.TotalTokens,
|
|
TotalDurationMs = run.TotalDurationMs,
|
|
FailureStage = run.FailureStage,
|
|
ErrorMessage = run.ErrorMessage,
|
|
HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase),
|
|
CanCommit = confirmation?.CanCommit == true,
|
|
ScenesCreated = commit?.ScenesCreated ?? 0,
|
|
CommitMessage = commit?.Notes,
|
|
Blockers = confirmation?.Blockers ?? [],
|
|
Warnings = confirmation?.Warnings ?? []
|
|
});
|
|
}
|
|
|
|
return new StoryIntelligenceProgressViewModel
|
|
{
|
|
BatchID = batch.BatchID,
|
|
PreviewID = batch.PreviewID,
|
|
ProjectID = batch.ProjectID,
|
|
BookID = batch.BookID,
|
|
ProjectName = batch.ProjectName,
|
|
BookTitle = batch.BookTitle,
|
|
Chapters = chapters
|
|
};
|
|
}
|
|
|
|
public async Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId)
|
|
{
|
|
var progress = await GetProgressAsync(batchId);
|
|
if (progress is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
foreach (var chapter in progress.Chapters.Where(chapter => chapter.IsActive))
|
|
{
|
|
await runs.RequestCancelAsync(chapter.RunID);
|
|
}
|
|
|
|
return await GetProgressAsync(batchId);
|
|
}
|
|
|
|
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId)
|
|
{
|
|
var progress = await GetProgressAsync(batchId);
|
|
if (progress is null || progress.Chapters.All(chapter => chapter.RunID != runId))
|
|
{
|
|
return (progress, new StoryIntelligenceImportCommitResult { Success = false, Message = "This chapter analysis could not be found." });
|
|
}
|
|
|
|
var result = await commits.CommitAsync(runId, RequireUserId());
|
|
return (await GetProgressAsync(batchId), result);
|
|
}
|
|
|
|
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
|
{
|
|
var progress = await GetProgressAsync(batchId);
|
|
if (progress is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new StoryIntelligenceCompletionViewModel
|
|
{
|
|
BatchID = progress.BatchID,
|
|
ProjectID = progress.ProjectID,
|
|
BookID = progress.BookID,
|
|
ProjectName = progress.ProjectName,
|
|
BookTitle = progress.BookTitle,
|
|
ChaptersCommitted = progress.CommittedChapterCount,
|
|
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated)
|
|
};
|
|
}
|
|
|
|
private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId)
|
|
{
|
|
var state = await onboardingRepository.GetAsync(userId);
|
|
if (state is null || !state.ProjectID.HasValue || !state.BookID.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var preview = await scanStore.GetLatestPreviewAsync(userId, state.UserOnboardingStateID);
|
|
if (preview is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var review = await scanStore.GetReviewAsync(userId, preview.PreviewID);
|
|
if (review is null || !string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var build = await scanStore.GetBuildResultAsync(userId, preview.PreviewID);
|
|
var wizard = await onboarding.GetWizardAsync();
|
|
return (state, preview, review, build, wizard);
|
|
}
|
|
|
|
private int RequireUserId()
|
|
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence.");
|
|
|
|
private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
|
|
{
|
|
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
|
return review.Chapters
|
|
.Where(chapter => chapter.Include && chapters.ContainsKey(chapter.TemporaryChapterKey))
|
|
.OrderBy(chapter => chapter.ChapterNumber)
|
|
.Select(chapter => new IncludedChapter(chapter, chapters[chapter.TemporaryChapterKey]))
|
|
.ToList();
|
|
}
|
|
|
|
private static int CountWords(string value)
|
|
=> string.IsNullOrWhiteSpace(value)
|
|
? 0
|
|
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
|
|
|
private sealed record IncludedChapter(ManuscriptScanChapterReviewDecision Decision, ManuscriptScanChapterPreview Preview);
|
|
}
|
|
|
|
public interface IOnboardingStoryIntelligenceBatchStore
|
|
{
|
|
Task SaveAsync(OnboardingStoryIntelligenceBatch batch);
|
|
Task<OnboardingStoryIntelligenceBatch?> GetAsync(int userId, Guid batchId);
|
|
Task<OnboardingStoryIntelligenceBatch?> GetLatestForPreviewAsync(int userId, Guid previewId);
|
|
}
|
|
|
|
public sealed class OnboardingStoryIntelligenceBatchStore : IOnboardingStoryIntelligenceBatchStore
|
|
{
|
|
private readonly ConcurrentDictionary<Guid, OnboardingStoryIntelligenceBatch> batches = new();
|
|
|
|
public Task SaveAsync(OnboardingStoryIntelligenceBatch batch)
|
|
{
|
|
batches[batch.BatchID] = batch;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<OnboardingStoryIntelligenceBatch?> GetAsync(int userId, Guid batchId)
|
|
=> Task.FromResult(batches.TryGetValue(batchId, out var batch) && batch.UserID == userId ? batch : null);
|
|
|
|
public Task<OnboardingStoryIntelligenceBatch?> GetLatestForPreviewAsync(int userId, Guid previewId)
|
|
=> Task.FromResult(batches.Values
|
|
.Where(batch => batch.UserID == userId && batch.PreviewID == previewId)
|
|
.OrderByDescending(batch => batch.CreatedUtc)
|
|
.FirstOrDefault());
|
|
}
|
|
|
|
public sealed class OnboardingStoryIntelligenceBatch
|
|
{
|
|
public Guid BatchID { get; init; } = Guid.NewGuid();
|
|
public int UserID { get; init; }
|
|
public int OnboardingID { get; init; }
|
|
public Guid PreviewID { get; init; }
|
|
public int ProjectID { get; init; }
|
|
public int BookID { get; init; }
|
|
public string? ProjectName { get; init; }
|
|
public string? BookTitle { get; init; }
|
|
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
|
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
|
}
|
|
|
|
public sealed class OnboardingStoryIntelligenceBatchItem
|
|
{
|
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
|
public int ChapterNumber { get; init; }
|
|
public string ChapterTitle { get; init; } = string.Empty;
|
|
public int ChapterID { get; init; }
|
|
public int RunID { get; init; }
|
|
}
|