PlotDirector/PlotLine/Services/OnboardingStoryIntelligenceService.cs

658 lines
27 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<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
}
public sealed class OnboardingStoryIntelligenceService(
IOnboardingRepository onboardingRepository,
IOnboardingService onboarding,
IManuscriptScanPreviewStore scanStore,
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceCharacterImportService characterImport,
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);
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,
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
}
};
}
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,
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 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
};
}
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 StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public bool CharacterStageComplete { 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 StoryIntelligenceCharacterImportBatchResult
{
public int CharactersCreated { get; init; }
public int CharactersLinked { get; init; }
public int CharactersIgnored { get; init; }
public int PovLinksResolved { get; init; }
public int ScenePeoplePanelsUpdated { get; init; }
}