574 lines
29 KiB
C#
574 lines
29 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);
|
|
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,
|
|
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);
|
|
|
|
await PersistAsync(
|
|
batch,
|
|
StoryIntelligenceReviewModules.Assets,
|
|
(await assetImport.BuildReviewAsync(batch)).Candidates,
|
|
candidate => candidate.Key,
|
|
candidate => candidate.AssetName,
|
|
candidate => candidate.Category,
|
|
candidate => candidate.ExistingAssetID,
|
|
candidate => candidate.PossibleAliases.Take(5));
|
|
|
|
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)
|
|
{
|
|
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
|
|
var allPendingPage = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, 1, MaxPageSize);
|
|
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, allPendingPage.Items);
|
|
return new StoryIntelligenceLocationReviewViewModel
|
|
{
|
|
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,
|
|
ParentLocationOptions = parentOptions,
|
|
SameLocationOptions = sameLocationOptions,
|
|
LocationTargetOptions = targetOptions,
|
|
Candidates = page.Items
|
|
};
|
|
}
|
|
|
|
public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
|
{
|
|
var page = await LoadPageAsync<StoryIntelligenceAssetReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize);
|
|
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,
|
|
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));
|
|
|
|
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
|
|
});
|
|
}
|
|
|
|
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 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);
|
|
}
|