PlotDirector/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs

449 lines
23 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 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,
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 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));
}
public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Locations,
(await locationImport.BuildReviewAsync(batch)).Candidates,
candidate => candidate.Key,
candidate => candidate.LocationName,
candidate => candidate.Category,
candidate => candidate.ExistingLocationID,
candidate => candidate.EvidenceSummaries.Take(5));
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<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);
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,
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
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0;
}
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);
}