PlotDirector/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs

670 lines
34 KiB
C#

using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceReviewCandidateService
{
Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize, string scope = "significant");
Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<int> MarkCharacterDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceCharacterImportChoiceForm> choices);
Task<int> MarkLocationDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceLocationImportChoiceForm> choices);
Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices);
Task<int> MarkRelationshipDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices);
Task<int> MarkKnowledgeDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices);
Task<int> CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module);
}
public sealed class StoryIntelligenceReviewCandidateService(
IStoryIntelligenceReviewCandidateRepository candidates,
IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
ICharacterRepository characters,
ILocationRepository locations,
IAssetRepository assets,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService
{
private const int MaxPageSize = 100;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
var automatic = await characterImport.ResolveAutomaticMatchesAsync(batch);
var review = await characterImport.BuildReviewAsync(batch);
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Characters,
review.Candidates,
candidate => candidate.Key,
candidate => candidate.CharacterName,
candidate => candidate.Classification,
candidate => candidate.ExistingCharacterID,
candidate => candidate.PossibleAliases.Take(5));
if (automatic.CandidatesResolved > 0)
{
logger.LogInformation(
"Persisted Story Intelligence character review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneAppearancesLinked={SceneAppearancesLinked}",
batch.BatchID,
batch.ProjectID,
batch.BookID,
automatic.CandidatesResolved,
automatic.SceneAppearancesLinked);
}
}
public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
await PrepareLocationCandidatesAsync(batch);
var automaticAssets = await assetImport.ResolveAutomaticMatchesAsync(batch);
var assetReview = await assetImport.BuildReviewAsync(batch);
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Assets,
assetReview.Candidates,
candidate => candidate.Key,
candidate => candidate.AssetName,
candidate => candidate.Category,
candidate => candidate.ExistingAssetID,
candidate => candidate.PossibleAliases.Take(5));
if (automaticAssets.CandidatesResolved > 0)
{
logger.LogInformation(
"Persisted Story Intelligence asset review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneEventsCreated={SceneEventsCreated} OwnershipLinksCreated={OwnershipLinksCreated}",
batch.BatchID,
batch.ProjectID,
batch.BookID,
automaticAssets.CandidatesResolved,
automaticAssets.SceneEventsCreated,
automaticAssets.OwnershipLinksCreated);
}
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Relationships,
(await relationshipImport.BuildReviewAsync(batch)).Candidates,
candidate => candidate.Key,
candidate => $"{candidate.CharacterAName} / {candidate.CharacterBName}",
candidate => candidate.RelationshipType,
candidate => candidate.ExistingRelationshipID,
candidate => new[] { candidate.ExampleScene, candidate.ExampleContext }.Where(value => !string.IsNullOrWhiteSpace(value))!);
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Knowledge,
(await knowledgeImport.BuildReviewAsync(batch)).Candidates,
candidate => candidate.Key,
candidate => candidate.KnowledgeStatement,
candidate => candidate.KnowledgeStateName,
candidate => candidate.ExistingKnowledgeID,
candidate => new[] { candidate.FirstScene, candidate.ExampleScene, candidate.Evidence }.Where(value => !string.IsNullOrWhiteSpace(value))!);
}
public async Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
var automatic = await locationImport.ResolveAutomaticMatchesAsync(batch);
var review = await locationImport.BuildReviewAsync(batch);
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Locations,
review.Candidates,
candidate => candidate.Key,
candidate => candidate.LocationName,
candidate => candidate.Category,
candidate => candidate.ExistingLocationID,
candidate => candidate.EvidenceSummaries.Take(5));
if (automatic.CandidatesResolved > 0)
{
logger.LogInformation(
"Persisted Story Intelligence location review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneLocationsLinked={SceneLocationsLinked}",
batch.BatchID,
batch.ProjectID,
batch.BookID,
automatic.CandidatesResolved,
automatic.SceneLocationsLinked);
}
}
public async Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var page = await LoadPageAsync<StoryIntelligenceCharacterReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize);
var existingOptions = (await characters.ListCharactersAsync(batch.ProjectID))
.Select(character => new StoryIntelligenceCharacterExistingOptionViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName
})
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
var candidatesOnPage = page.Items
.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
{
Key = candidate.Key,
CharacterName = candidate.CharacterName,
ImportName = candidate.ImportName,
DefaultAction = candidate.DefaultAction,
Classification = candidate.Classification,
AppearsInScenes = candidate.AppearsInScenes,
Confidence = candidate.Confidence,
PossibleAliases = candidate.PossibleAliases,
ExampleFirstAppearance = candidate.ExampleFirstAppearance,
NameIntelligenceWarning = candidate.NameIntelligenceWarning,
ExistingCharacterID = candidate.ExistingCharacterID,
ExistingCharacterName = candidate.ExistingCharacterName,
SelectedExistingCharacterID = candidate.SelectedExistingCharacterID,
ExistingCharacterOptions = existingOptions
})
.ToList();
return new StoryIntelligenceCharacterReviewViewModel
{
HasCommittedScenes = true,
CanImport = candidatesOnPage.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
GenericReferenceCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.GenericReference),
ExistingMatchCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter),
NewCandidateCount = candidatesOnPage.Count(candidate => candidate.Classification is StoryIntelligenceCharacterClassifications.NewCharacter or StoryIntelligenceCharacterClassifications.NewAlias),
AmbiguousCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.Ambiguous),
AliasTargetOptions = existingOptions,
Candidates = candidatesOnPage
};
}
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize, string scope = "significant")
{
scope = scope is "all" or "uncertain" ? scope : "significant";
var generation = await candidates.GetCurrentGenerationAsync(batch.BookID, StoryIntelligenceReviewModules.Locations);
var rows = await candidates.ListLocationsAsync(batch.BookID);
var all = rows.Select(row => {
var item = JsonSerializer.Deserialize<StoryIntelligenceLocationReviewCandidateViewModel>(row.CandidatePayloadJson, JsonOptions)!;
item.DecisionStatus = row.DecisionStatus;
return item;
}).ToList();
var ordered = LocationSignificanceService.OrderHierarchy(all, scope);
pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
pageNumber = Math.Clamp(pageNumber, 1, Math.Max(1, (int)Math.Ceiling(ordered.Count / (double)pageSize)));
var items = ordered.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList();
var pendingCount = all.Count(x => x.IsPending);
var uncertainCount = all.Count(x => x.IsPending && x.NeedsReview);
var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList();
var parentOptions = projectLocations
.OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase)
.ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
.Select(location => new StoryIntelligenceLocationParentOptionViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationName,
LocationPath = location.LocationPath
})
.ToList();
var sameLocationOptions = await BuildSameLocationOptionsAsync(projectLocations);
var targetOptions = await BuildLocationTargetOptionsAsync(projectLocations, all.Where(x => x.IsPending).ToList());
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = true,
CanImport = items.Any(x => x.IsPending),
IsComplete = generation?.Status == StoryIntelligenceReviewCandidateGenerationStatuses.Ready && pendingCount == 0,
Scope = scope,
SignificantCount = all.Count(x => x.Significance?.IsSignificant == true),
UncertainCount = uncertainCount,
FilteredCount = ordered.Count,
SummaryNeedsAttentionCount = uncertainCount,
PageNumber = pageNumber,
PageSize = pageSize,
TotalCandidateCount = all.Count,
PendingCandidateCount = pendingCount,
ParentLocationOptions = parentOptions,
SameLocationOptions = sameLocationOptions,
LocationTargetOptions = targetOptions,
Candidates = items
};
}
public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var page = await LoadPageAsync<StoryIntelligenceAssetReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize);
var allPendingPage = await LoadPageAsync<StoryIntelligenceAssetReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Assets, 1, MaxPageSize);
var projectAssets = (await assets.ListAssetsAsync(batch.ProjectID)).ToList();
var targetOptions = await BuildAssetTargetOptionsAsync(projectAssets, allPendingPage.Items);
return new StoryIntelligenceAssetReviewViewModel
{
HasCommittedScenes = true,
CanImport = page.Items.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
AssetTargetOptions = targetOptions,
Candidates = page.Items
};
}
public async Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var page = await LoadPageAsync<StoryIntelligenceRelationshipReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Relationships, pageNumber, pageSize);
return new StoryIntelligenceRelationshipReviewViewModel
{
HasCommittedScenes = true,
CanImport = page.Items.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
RelationshipTypeOptions = lookupData.RelationshipTypes
.OrderBy(type => type.SortOrder)
.ThenBy(type => type.TypeName)
.Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
.ToList(),
Candidates = page.Items
};
}
public async Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var page = await LoadPageAsync<StoryIntelligenceKnowledgeReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Knowledge, pageNumber, pageSize);
return new StoryIntelligenceKnowledgeReviewViewModel
{
HasCommittedScenes = true,
CanImport = page.Items.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
KnowledgeStateOptions = lookupData.KnowledgeStates
.OrderBy(state => state.SortOrder)
.ThenBy(state => state.StateName)
.Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString()))
.ToList(),
Candidates = page.Items
};
}
public async Task<int> MarkCharacterDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceCharacterImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Characters,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.ExistingCharacterID ?? choice.AliasTargetCharacterID
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Characters))?.PendingCount ?? 0;
}
public async Task<int> MarkLocationDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceLocationImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Locations,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.ExistingLocationID
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0;
}
private async Task<IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel>> BuildSameLocationOptionsAsync(IReadOnlyList<LocationItem> projectLocations)
{
var options = new List<StoryIntelligenceLocationIdentityOptionViewModel>();
foreach (var location in projectLocations)
{
var aliasNames = (await locations.ListAliasesAsync(location.LocationID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase)
.ToList();
options.Add(new StoryIntelligenceLocationIdentityOptionViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationName,
AliasSummary = string.Join(", ", aliasNames.Take(4))
});
}
return options
.OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase)
.ThenBy(location => location.LocationID)
.ToList();
}
private async Task<IReadOnlyList<StoryIntelligenceLocationTargetOptionViewModel>> BuildLocationTargetOptionsAsync(
IReadOnlyList<LocationItem> projectLocations,
IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> pendingCandidates)
{
var canonicalKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var options = new List<StoryIntelligenceLocationTargetOptionViewModel>();
foreach (var location in projectLocations)
{
var aliasNames = (await locations.ListAliasesAsync(location.LocationID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase)
.ToList();
canonicalKeys.Add(LocationTargetKey(location.LocationName));
options.Add(new StoryIntelligenceLocationTargetOptionViewModel
{
Value = $"existing:{location.LocationID}",
LocationID = location.LocationID,
DisplayName = location.LocationName,
GroupName = "Existing locations",
AliasSummary = string.Join(", ", aliasNames.Take(4))
});
}
foreach (var candidate in pendingCandidates.Where(candidate => !candidate.ExistingLocationID.HasValue))
{
var key = LocationTargetKey(candidate.LocationName);
if (string.IsNullOrWhiteSpace(key) || canonicalKeys.Contains(key))
{
continue;
}
options.Add(new StoryIntelligenceLocationTargetOptionViewModel
{
Value = $"candidate:{candidate.Key}",
CandidateKey = candidate.Key,
DisplayName = candidate.LocationName,
GroupName = "Pending suggestions"
});
}
return options
.OrderBy(option => option.GroupName, StringComparer.Ordinal)
.ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase)
.ThenBy(option => option.Value, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string LocationTargetKey(string? name)
=> StoryIntelligenceEntityTextNormaliser.SafeKey(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty));
private async Task<IReadOnlyList<StoryIntelligenceAssetTargetOptionViewModel>> BuildAssetTargetOptionsAsync(
IReadOnlyList<StoryAsset> projectAssets,
IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> pendingCandidates)
{
var canonicalKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var options = new List<StoryIntelligenceAssetTargetOptionViewModel>();
foreach (var asset in projectAssets)
{
var aliasNames = (await assets.ListAliasesAsync(asset.StoryAssetID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, asset.AssetName, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase)
.ToList();
canonicalKeys.Add(AssetTargetKey(asset.AssetName));
foreach (var alias in aliasNames)
{
canonicalKeys.Add(AssetTargetKey(alias));
}
options.Add(new StoryIntelligenceAssetTargetOptionViewModel
{
Value = $"existing:{asset.StoryAssetID}",
AssetID = asset.StoryAssetID,
DisplayName = asset.AssetName,
GroupName = "Existing assets",
AliasSummary = string.Join(", ", aliasNames.Take(4))
});
}
foreach (var candidate in pendingCandidates.Where(candidate => !candidate.ExistingAssetID.HasValue))
{
var key = AssetTargetKey(candidate.AssetName);
if (string.IsNullOrWhiteSpace(key) || canonicalKeys.Contains(key))
{
continue;
}
options.Add(new StoryIntelligenceAssetTargetOptionViewModel
{
Value = $"candidate:{candidate.Key}",
CandidateKey = candidate.Key,
DisplayName = candidate.AssetName,
GroupName = "Pending suggestions"
});
}
return options
.OrderBy(option => option.GroupName, StringComparer.Ordinal)
.ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase)
.ThenBy(option => option.Value, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string AssetTargetKey(string? name)
=> StoryIntelligenceEntityTextNormaliser.SafeKey(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty));
public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Assets,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.ExistingAssetID
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Assets))?.PendingCount ?? 0;
}
public async Task<int> MarkRelationshipDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Relationships,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.RelationshipTypeID
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Relationships))?.PendingCount ?? 0;
}
public async Task<int> MarkKnowledgeDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Knowledge,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.KnowledgeStateID
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Knowledge))?.PendingCount ?? 0;
}
private async Task PersistAsync<TCandidate>(
OnboardingStoryIntelligenceBatch batch,
string module,
IReadOnlyList<TCandidate> sourceCandidates,
Func<TCandidate, string> stableKey,
Func<TCandidate, string> displayName,
Func<TCandidate, string?> candidateType,
Func<TCandidate, int?> existingCanonicalId,
Func<TCandidate, IEnumerable<string?>> evidence)
{
var generation = await candidates.BeginGenerationAsync(batch.ProjectID, batch.BookID, module, "SceneIntelligencePreparation");
try
{
var sortOrder = 0;
foreach (var candidate in sourceCandidates)
{
sortOrder++;
var row = await candidates.SaveCandidateAsync(new StoryIntelligenceReviewCandidateSaveRequest
{
ReviewCandidateGenerationID = generation.ReviewCandidateGenerationID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
Module = module,
StableKey = stableKey(candidate),
CandidateType = candidateType(candidate),
DisplayName = displayName(candidate),
SortOrder = sortOrder,
ExistingCanonicalID = existingCanonicalId(candidate),
CandidatePayloadJson = JsonSerializer.Serialize(candidate, JsonOptions)
});
var evidenceOrder = 0;
foreach (var item in evidence(candidate).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).Take(5))
{
evidenceOrder++;
await candidates.SaveEvidenceAsync(row.ReviewCandidateID, null, item!, null, evidenceOrder);
}
}
var completedGeneration = await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID);
await CompleteModuleIfReadyAsync(batch, module, completedGeneration);
logger.LogInformation(
"Prepared persisted Story Intelligence review candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} Module={Module} Count={Count}",
batch.BatchID,
batch.ProjectID,
batch.BookID,
module,
sourceCandidates.Count);
}
catch (Exception ex)
{
await candidates.FailGenerationAsync(generation.ReviewCandidateGenerationID, ex.Message);
logger.LogError(ex, "Persisted Story Intelligence review candidate preparation failed. BatchID={BatchID} BookID={BookID} Module={Module}", batch.BatchID, batch.BookID, module);
throw;
}
}
public async Task<int> CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module)
{
var generation = await candidates.GetCurrentGenerationAsync(batch.BookID, module);
return await CompleteModuleIfReadyAsync(batch, module, generation);
}
private async Task<int> CompleteModuleIfReadyAsync(
OnboardingStoryIntelligenceBatch batch,
string module,
StoryIntelligenceReviewCandidateGeneration? generation)
{
if (generation is null
|| !string.Equals(generation.Status, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase)
|| generation.PendingCount != 0)
{
return generation?.PendingCount ?? 0;
}
switch (module)
{
case StoryIntelligenceReviewModules.Characters:
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
break;
case StoryIntelligenceReviewModules.Locations:
await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID);
break;
case StoryIntelligenceReviewModules.Assets:
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
break;
case StoryIntelligenceReviewModules.Relationships:
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
break;
case StoryIntelligenceReviewModules.Knowledge:
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
break;
default:
logger.LogWarning("Ignoring unsupported Story Intelligence review completion module {Module}. BatchID={BatchID} BookID={BookID}", module, batch.BatchID, batch.BookID);
break;
}
return 0;
}
private async Task<CandidatePage<TCandidate>> LoadPageAsync<TCandidate>(int bookId, string module, int pageNumber, int pageSize)
{
pageNumber = Math.Max(1, pageNumber);
pageSize = Math.Clamp(pageSize <= 0 ? 25 : pageSize, 1, MaxPageSize);
var generation = await candidates.GetCurrentGenerationAsync(bookId, module);
if (generation is null || !string.Equals(generation.Status, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase))
{
return new CandidatePage<TCandidate>([], pageNumber, pageSize, 0, 0, false);
}
var maxPage = generation.PendingCount <= 0
? 1
: (int)Math.Ceiling(generation.PendingCount / (double)pageSize);
pageNumber = Math.Clamp(pageNumber, 1, maxPage);
var rows = await candidates.ListPageAsync(bookId, module, pageNumber, pageSize);
var items = rows.Select(row => JsonSerializer.Deserialize<TCandidate>(row.CandidatePayloadJson, JsonOptions))
.Where(item => item is not null)
.Select(item => item!)
.ToList();
var totalCount = rows.FirstOrDefault()?.TotalCount ?? generation.CandidateCount;
var pendingCount = rows.FirstOrDefault()?.PendingCount ?? generation.PendingCount;
return new CandidatePage<TCandidate>(items, pageNumber, pageSize, totalCount, pendingCount, true);
}
private sealed record CandidatePage<TCandidate>(
IReadOnlyList<TCandidate> Items,
int PageNumber,
int PageSize,
int TotalCount,
int PendingCount,
bool GenerationReady);
}