PlotDirector/PlotLine/Services/OnboardingStoryIntelligenceService.cs

1048 lines
47 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<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form);
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form);
Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(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,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
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);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
var knowledgeReview = await knowledgeImport.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,
RelationshipReview = relationshipReview,
KnowledgeReview = knowledgeReview,
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,
RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count,
RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked),
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete,
KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count,
KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked),
KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.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<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm 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 relationshipImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceRelationshipImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
RelationshipsCreated = batch.LastRelationshipImportResult?.RelationshipsCreated ?? 0,
RelationshipsLinked = batch.LastRelationshipImportResult?.RelationshipsLinked ?? 0,
RelationshipsMerged = batch.LastRelationshipImportResult?.RelationshipsMerged ?? 0,
RelationshipsIgnored = batch.LastRelationshipImportResult?.RelationshipsIgnored ?? 0,
RelationshipEventsCreated = batch.LastRelationshipImportResult?.RelationshipEventsCreated ?? 0
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm 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 knowledgeImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceKnowledgeImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
KnowledgeCreated = batch.LastKnowledgeImportResult?.KnowledgeCreated ?? 0,
KnowledgeLinked = batch.LastKnowledgeImportResult?.KnowledgeLinked ?? 0,
KnowledgeMerged = batch.LastKnowledgeImportResult?.KnowledgeMerged ?? 0,
KnowledgeIgnored = batch.LastKnowledgeImportResult?.KnowledgeIgnored ?? 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,
RelationshipsCreatedOrLinked = progress.PipelineDashboard.RelationshipsCreatedOrLinked,
RelationshipStageComplete = progress.PipelineDashboard.RelationshipStageComplete
};
}
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()
};
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview
or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.Complete;
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.Complete;
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.Complete;
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.Complete;
var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport);
batch.CharacterStageComplete = characterStageComplete;
batch.LocationStageComplete = locationStageComplete;
batch.AssetStageComplete = assetStageComplete;
batch.RelationshipStageComplete = relationshipStageComplete;
batch.KnowledgeStageComplete = knowledgeStageComplete;
await batchStore.SaveAsync(batch);
var route = knowledgeStageComplete
? StoryIntelligenceResumeRoutes.Complete
: relationshipStageComplete
? StoryIntelligenceResumeRoutes.Knowledge
: assetStageComplete
? StoryIntelligenceResumeRoutes.Relationships
: locationStageComplete
? StoryIntelligenceResumeRoutes.Assets
: characterStageComplete
? StoryIntelligenceResumeRoutes.Locations
: state.CurrentStage switch
{
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge,
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 static bool HasCompletedStage(StoryIntelligenceBookPipelineState state, string stage)
=> string.Equals(state.LastCompletedStage, stage, StringComparison.OrdinalIgnoreCase);
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 List<OnboardingStoryIntelligenceRelationshipSelection> RelationshipSelections { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
public List<OnboardingStoryIntelligenceKnowledgeSelection> KnowledgeSelections { get; } = [];
public List<OnboardingStoryIntelligenceKnowledgeDecision> KnowledgeDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; }
public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; }
public bool RelationshipStageComplete { get; set; }
public bool KnowledgeStageComplete { 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 OnboardingStoryIntelligenceRelationshipDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public int? CharacterRelationshipID { get; init; }
public string? RelationshipLabel { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceRelationshipSelection
{
public string Key { get; init; } = string.Empty;
public int RelationshipTypeID { 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 sealed class StoryIntelligenceRelationshipImportBatchResult
{
public int RelationshipsCreated { get; init; }
public int RelationshipsLinked { get; init; }
public int RelationshipsMerged { get; init; }
public int RelationshipsIgnored { get; init; }
public int RelationshipEventsCreated { get; init; }
}
public sealed class OnboardingStoryIntelligenceKnowledgeDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public int? CharacterKnowledgeID { get; init; }
public string? KnowledgeLabel { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceKnowledgeSelection
{
public string Key { get; init; } = string.Empty;
public int KnowledgeStateID { get; init; }
}
public sealed class StoryIntelligenceKnowledgeImportBatchResult
{
public int KnowledgeCreated { get; init; }
public int KnowledgeLinked { get; init; }
public int KnowledgeMerged { get; init; }
public int KnowledgeIgnored { 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 Relationships = "Relationships";
public const string Knowledge = "Knowledge";
public const string Complete = "Complete";
}
public sealed record OnboardingStoryIntelligenceResumeTarget(Guid BatchID, string Route);