1081 lines
50 KiB
C#
1081 lines
50 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(string? appearancePreference = null);
|
|
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(string? appearancePreference = null)
|
|
{
|
|
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,
|
|
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
|
|
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;
|
|
}
|
|
|
|
private static string NormaliseAppearancePreference(string? value)
|
|
=> value?.Trim() switch
|
|
{
|
|
StoryMemoryAppearancePreferences.ExplicitEvidenceOnly => StoryMemoryAppearancePreferences.ExplicitEvidenceOnly,
|
|
StoryMemoryAppearancePreferences.Balanced => StoryMemoryAppearancePreferences.Balanced,
|
|
StoryMemoryAppearancePreferences.PredominantlyLight => StoryMemoryAppearancePreferences.PredominantlyLight,
|
|
StoryMemoryAppearancePreferences.PredominantlyMedium => StoryMemoryAppearancePreferences.PredominantlyMedium,
|
|
StoryMemoryAppearancePreferences.PredominantlyDark => StoryMemoryAppearancePreferences.PredominantlyDark,
|
|
StoryMemoryAppearancePreferences.CustomMix => StoryMemoryAppearancePreferences.CustomMix,
|
|
_ => StoryMemoryAppearancePreferences.Balanced
|
|
};
|
|
|
|
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,
|
|
UpdatedUtc = run.UpdatedUtc,
|
|
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);
|
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
|
return new StoryIntelligenceProgressViewModel
|
|
{
|
|
BatchID = batch.BatchID,
|
|
PreviewID = batch.PreviewID,
|
|
ImportSessionID = pipeline?.StoryIntelligenceBookPipelineID,
|
|
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))
|
|
.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 activeRuns = committedRuns.Count > 0
|
|
? []
|
|
: (await runs.ListRunsByBookForUserAsync(bookId, userId))
|
|
.Where(run => run.BookID == state.BookID)
|
|
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
|
|
.ThenBy(run => run.StoryIntelligenceRunID)
|
|
.Select(run => new OnboardingStoryIntelligenceBatchItem
|
|
{
|
|
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
|
|
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
|
|
ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!,
|
|
ChapterID = run.ChapterID ?? 0,
|
|
RunID = run.StoryIntelligenceRunID
|
|
})
|
|
.ToList();
|
|
var batchItems = committedRuns.Count > 0 ? committedRuns : activeRuns;
|
|
if (batchItems.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 = batchItems
|
|
};
|
|
|
|
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 string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
|
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);
|