403 lines
21 KiB
C#
403 lines
21 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);
|
|
}
|
|
|
|
public sealed class StoryIntelligenceReviewCandidateService(
|
|
IStoryIntelligenceReviewCandidateRepository candidates,
|
|
IStoryIntelligenceCharacterImportService characterImport,
|
|
IStoryIntelligenceLocationImportService locationImport,
|
|
IStoryIntelligenceAssetImportService assetImport,
|
|
IStoryIntelligenceRelationshipImportService relationshipImport,
|
|
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
|
ICharacterRepository characters,
|
|
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);
|
|
}
|
|
}
|
|
|
|
await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID);
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|