PlotDirector/PlotLine/Services/OnboardingStoryIntelligenceService.cs

876 lines
38 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
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<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
}
public sealed class OnboardingStoryIntelligenceService(
IOnboardingRepository onboardingRepository,
IOnboardingService onboarding,
IManuscriptScanPreviewStore scanStore,
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
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
});
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
{
RunID = runId,
UserID = userId,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
ChapterID = chapterId,
ChapterNumber = chapter.Decision.ChapterNumber,
Status = StoryIntelligenceRunStatuses.Pending,
CurrentStage = "Queued",
CurrentMessage = $"{chapter.Decision.Title} is queued for analysis.",
EventType = "Import started",
ElapsedTime = "0 sec",
EstimatedRemaining = "Calculating",
IsActive = true
});
}
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;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
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,
SourceWordCount = run.SourceWordCount,
TotalTokens = run.TotalTokens,
TotalDurationMs = run.TotalDurationMs,
EstimatedCostUSD = run.EstimatedCostUSD,
EstimatedCostGBP = run.EstimatedCostGBP,
FailureStage = run.FailureStage,
ErrorMessage = run.ErrorMessage,
LatestSceneSummary = LatestSceneSummary(sceneResults),
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 ?? [],
Scenes = confirmation is null
? []
: BuildScenePreviews(confirmation.Scenes)
});
}
var characterReview = await characterImport.BuildReviewAsync(batch);
var locationReview = await locationImport.BuildReviewAsync(batch);
var assetReview = await assetImport.BuildReviewAsync(batch);
return new StoryIntelligenceProgressViewModel
{
BatchID = batch.BatchID,
PreviewID = batch.PreviewID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
ProjectName = batch.ProjectName,
BookTitle = batch.BookTitle,
Chapters = chapters,
CharacterReview = characterReview,
LocationReview = locationReview,
AssetReview = assetReview,
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated),
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete,
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete,
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete
}
};
}
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<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId)
{
var progress = await GetProgressAsync(batchId);
if (progress is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var userId = RequireUserId();
var created = 0;
foreach (var chapter in progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).Where(chapter => chapter.CanCommit))
{
var result = await commits.CommitAsync(chapter.RunID, userId);
if (!result.Success)
{
return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult
{
Success = false,
Message = $"Scene creation stopped. Chapter {chapter.ChapterNumber:N0} could not be imported. Reason: {result.Message}",
ScenesCreated = created
});
}
created += result.ScenesCreated;
}
await onboarding.MarkCompletedAsync();
return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = created,
Message = $"Scenes successfully created. {created:N0} scene(s) added to PlotDirector."
});
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await characterImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceCharacterImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0,
CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0,
CharactersAliased = batch.LastCharacterImportResult?.CharactersAliased ?? 0,
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await locationImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceLocationImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
LocationsCreated = batch.LastLocationImportResult?.LocationsCreated ?? 0,
LocationsLinked = batch.LastLocationImportResult?.LocationsLinked ?? 0,
LocationAliasesAdded = batch.LastLocationImportResult?.LocationAliasesAdded ?? 0,
LocationsIgnored = batch.LastLocationImportResult?.LocationsIgnored ?? 0,
SceneLocationsUpdated = batch.LastLocationImportResult?.SceneLocationsUpdated ?? 0
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await assetImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceAssetImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
AssetsCreated = batch.LastAssetImportResult?.AssetsCreated ?? 0,
AssetsLinked = batch.LastAssetImportResult?.AssetsLinked ?? 0,
AssetAliasesAdded = batch.LastAssetImportResult?.AssetAliasesAdded ?? 0,
AssetsIgnored = batch.LastAssetImportResult?.AssetsIgnored ?? 0,
SceneAssetEventsCreated = batch.LastAssetImportResult?.SceneAssetEventsCreated ?? 0,
OwnershipLinksCreated = batch.LastAssetImportResult?.OwnershipLinksCreated ?? 0
};
}
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,
FirstChapterID = progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).FirstOrDefault()?.ChapterID,
ProjectName = progress.ProjectName,
BookTitle = progress.BookTitle,
ChaptersCommitted = progress.CommittedChapterCount,
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated),
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete,
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked,
AssetStageComplete = progress.PipelineDashboard.AssetStageComplete
};
}
public async Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId)
{
var userId = RequireUserId();
var state = await pipelineState.GetForBookAsync(bookId, userId);
if (state is null)
{
return null;
}
var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId);
if (committedRuns.Count == 0)
{
return null;
}
var batch = new OnboardingStoryIntelligenceBatch
{
UserID = userId,
OnboardingID = 0,
PreviewID = Guid.Empty,
ProjectID = state.ProjectID,
BookID = state.BookID,
ProjectName = state.ProjectName,
BookTitle = state.BookDisplayTitle,
Items = committedRuns
.OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
.ToList()
};
if (state.IsComplete)
{
batch.CharacterStageComplete = true;
batch.LocationStageComplete = true;
batch.AssetStageComplete = true;
}
else if (state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport)
{
batch.CharacterStageComplete = true;
batch.LocationStageComplete = true;
}
else if (state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport)
{
batch.CharacterStageComplete = true;
}
await batchStore.SaveAsync(batch);
var route = state.CurrentStage switch
{
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete,
StoryIntelligencePipelineStages.AssetReview => StoryIntelligenceResumeRoutes.Assets,
StoryIntelligencePipelineStages.AssetImport => StoryIntelligenceResumeRoutes.Assets,
StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations,
StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations,
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
StoryIntelligencePipelineStages.SceneImport => StoryIntelligenceResumeRoutes.SceneReview,
_ => StoryIntelligenceResumeRoutes.SceneReview
};
return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route);
}
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 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 IReadOnlyList<StoryIntelligenceOnboardingScenePreviewViewModel> BuildScenePreviews(IReadOnlyList<StoryIntelligenceSceneImportItem> importScenes)
=> importScenes
.OrderBy(scene => scene.TemporarySceneNumber)
.Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel
{
SceneNumber = scene.TemporarySceneNumber,
Summary = Trim(FirstConfigured(scene.ParsedScene.Summary?.Short, scene.ParsedScene.Summary?.Detailed, $"Scene {scene.TemporarySceneNumber:N0}")!, 180),
Pov = scene.PovCharacterID.HasValue ? FirstConfigured(scene.ParsedScene.PointOfView?.CharacterName, scene.ParsedScene.PointOfView?.NarrativeMode) : null,
Setting = BuildSetting(scene.ParsedScene.Setting),
Confidence = scene.ParsedScene.Summary?.Confidence ?? scene.ParsedScene.Setting?.Confidence,
HasWarnings = false
})
.ToList();
private static SceneIntelligenceScene? TryReadScene(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
{
return direct;
}
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
}
catch (JsonException)
{
return null;
}
return null;
}
private static string? BuildSetting(SceneIntelligenceSetting? setting)
=> setting is null
? null
: FirstConfigured(
setting.LocationName,
setting.GenericRoomType,
setting.LocationType,
setting.DateOrTimeReference,
setting.TimeOfDay);
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
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 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);
Task<OnboardingStoryIntelligenceBatch?> GetLatestActiveForUserAsync(int userId);
}
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 Task<OnboardingStoryIntelligenceBatch?> GetLatestActiveForUserAsync(int userId)
=> Task.FromResult(batches.Values
.Where(batch => batch.UserID == userId)
.OrderByDescending(batch => batch.CreatedUtc)
.FirstOrDefault(batch => batch.Items.Count > 0));
}
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 List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; }
}
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; }
}
public sealed class OnboardingStoryIntelligenceCharacterDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public string? CharacterName { get; init; }
public int? CharacterID { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceLocationDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public string? LocationName { get; init; }
public int? LocationID { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceAssetDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public string? AssetName { get; init; }
public int? StoryAssetID { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class StoryIntelligenceCharacterImportBatchResult
{
public int CharactersCreated { get; init; }
public int CharactersLinked { get; init; }
public int CharactersAliased { get; init; }
public int CharactersIgnored { get; init; }
public int PovLinksResolved { get; init; }
public int ScenePeoplePanelsUpdated { get; init; }
}
public sealed class StoryIntelligenceLocationImportBatchResult
{
public int LocationsCreated { get; init; }
public int LocationsLinked { get; init; }
public int LocationAliasesAdded { get; init; }
public int LocationsIgnored { get; init; }
public int SceneLocationsUpdated { get; init; }
}
public sealed class StoryIntelligenceAssetImportBatchResult
{
public int AssetsCreated { get; init; }
public int AssetsLinked { get; init; }
public int AssetAliasesAdded { get; init; }
public int AssetsIgnored { get; init; }
public int SceneAssetEventsCreated { get; init; }
public int OwnershipLinksCreated { get; init; }
}
public static class StoryIntelligenceResumeRoutes
{
public const string SceneReview = "SceneReview";
public const string Characters = "Characters";
public const string Locations = "Locations";
public const string Assets = "Assets";
public const string Complete = "Complete";
}
public sealed record OnboardingStoryIntelligenceResumeTarget(Guid BatchID, string Route);