diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 08fd69b..8e5fb3c 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -62,6 +62,7 @@ var tests = new (string Name, Action Test)[] ("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership), ("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns), ("Asset significance is generic and not fixture-specific", AssetSignificanceIsGenericAndNotFixtureSpecific), + ("Story Intelligence asset review applies current identity rules", StoryIntelligenceAssetReviewAppliesCurrentIdentityRules), ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes), ("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance), ("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder), @@ -1059,11 +1060,14 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering() foreach (var viewName in heavyViews) { var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName)); - if (string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal)) + if (string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal) + || string.Equals(viewName, "StoryIntelligenceAssets.cshtml", StringComparison.Ordinal)) { var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); - Assert(onboarding.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), $"{viewName} should use persisted bounded candidate pages."); - Assert(view.Contains("var locationCandidates = Model.LocationReview.Candidates.ToList();", StringComparison.Ordinal), $"{viewName} should render only the persisted candidate page."); + var module = string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal) ? "Location" : "Asset"; + var candidateVariable = string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal) ? "locationCandidates" : "assetCandidates"; + Assert(onboarding.Contains($"reviewCandidates.Get{module}ReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), $"{viewName} should use persisted bounded candidate pages."); + Assert(view.Contains($"var {candidateVariable} = Model.{module}Review.Candidates.ToList();", StringComparison.Ordinal), $"{viewName} should render only the persisted candidate page."); } else { @@ -2988,6 +2992,66 @@ static void AssetSignificanceIsGenericAndNotFixtureSpecific() Assert(!service.Contains("\"tr6\"", StringComparison.OrdinalIgnoreCase), "Production asset logic must not special-case TR6."); } +static void StoryIntelligenceAssetReviewAppliesCurrentIdentityRules() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceAssetImportService.cs")); + var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs")); + var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); + var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAssets.cshtml")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + + Assert(service.Contains("Task ResolveAutomaticMatchesAsync", StringComparison.Ordinal), + "Asset import should expose deterministic auto-resolution before persisted review generation."); + Assert(candidateService.Contains("await assetImport.ResolveAutomaticMatchesAsync(batch)", StringComparison.Ordinal), + "Persisted Asset review preparation should resolve exact known identities before saving review rows."); + Assert(service.Contains("assets.ListAliasesAsync(asset.StoryAssetID)", StringComparison.Ordinal), + "Asset preferred names and aliases should feed identity lookup."); + Assert(service.Contains("Dictionary> byName", StringComparison.Ordinal), + "Asset identity lookup should retain all owners for ambiguous names."); + Assert(service.Contains("matches.Select(match => match.StoryAssetID).Distinct().Count() == 1", StringComparison.Ordinal), + "Asset identity lookup should only auto-match when exactly one AssetID owns the name."); + Assert(service.Contains("IsAutoResolvableKnownIdentity", StringComparison.Ordinal) + && service.Contains("existingIndex.Find(candidate.DisplayName)?.StoryAssetID == candidate.ExistingAssetID.Value", StringComparison.Ordinal), + "Exact known Asset identities should auto-resolve and hide from review."); + Assert(service.Contains("LinkSceneAssetEventsAsync(candidate, assetId", StringComparison.Ordinal), + "Auto-resolution and review import should preserve scene asset events."); + Assert(view.Contains("Same asset as", StringComparison.Ordinal), "Asset review should present one clear identity decision."); + Assert(!view.Contains("Link existing asset", StringComparison.Ordinal), "Asset review should not expose old link-existing wording."); + Assert(!view.Contains("Alias of another asset", StringComparison.Ordinal), "Asset review should not expose alias-chain wording."); + Assert(view.Contains("data-asset-unified-target", StringComparison.Ordinal), "Same-asset selector should use one unified target source."); + Assert(model.Contains("AssetTargetOptions", StringComparison.Ordinal), "Asset review model should expose unified Asset targets."); + Assert(candidateService.Contains("BuildAssetTargetOptionsAsync(projectAssets, allPendingPage.Items)", StringComparison.Ordinal), + "Persisted Asset review GET should hydrate canonical and pending targets."); + Assert(candidateService.Contains("canonicalKeys.Add(AssetTargetKey(alias))", StringComparison.Ordinal), + "Pending targets duplicating canonical aliases should be suppressed."); + Assert(candidateService.Contains(".ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal), + "Asset targets should be sorted alphabetically by display name within groups."); + Assert(view.Contains("IsSelfTarget(target, candidate)", StringComparison.Ordinal), "Asset selectors should exclude the current candidate."); + Assert(view.Contains("name=\"Assets[@i].ExistingAssetID\"", StringComparison.Ordinal), "Same-asset decisions should post canonical AssetID values."); + Assert(candidateService.Contains("DecisionCanonicalID = choice.ExistingAssetID", StringComparison.Ordinal), + "Persisted Asset decisions should record canonical AssetID targets."); + Assert(view.Contains("candidate.IsExistingMatch", StringComparison.Ordinal) + && view.Contains(" ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form) + public async Task ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form, string intent = "save") { var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form); if (progress is null) @@ -588,9 +588,26 @@ public sealed class OnboardingController( } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; - return result.Success - ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) - : RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); + if (!result.Success) + { + return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber) }); + } + + var pendingCount = progress.AssetReview.SummaryNeedsAttentionCount ?? progress.AssetReview.PendingCandidateCount; + if (pendingCount <= 0) + { + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }); + } + + const int pageSize = 25; + var pageCount = Math.Max(1, (int)Math.Ceiling(pendingCount / (double)pageSize)); + var currentPage = Math.Max(1, form.PageNumber); + var targetPage = string.Equals(intent, "previous", StringComparison.OrdinalIgnoreCase) + ? currentPage - 1 + : currentPage; + targetPage = Math.Clamp(targetPage, 1, pageCount); + + return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID, page = targetPage }); } [HttpPost("story-intelligence/relationships")] diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs index b21ffb5..149e24d 100644 --- a/PlotLine/Services/StoryIntelligenceAssetImportService.cs +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -8,10 +8,13 @@ namespace PlotLine.Services; public interface IStoryIntelligenceAssetImportService { + Task ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch); Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview); } +public sealed record StoryIntelligenceAssetAutoResolutionResult(int CandidatesResolved, int SceneEventsCreated, int OwnershipLinksCreated); + public sealed class StoryIntelligenceAssetImportService( IStoryIntelligenceResultRepository runs, IAssetRepository assets, @@ -53,6 +56,52 @@ public sealed class StoryIntelligenceAssetImportService( "child", "children", "aunt", "uncle", "wife", "husband", "partner", "friend", "neighbour", "neighbor", "teacher", "student", "examiner", "doctor", "nurse"); + public async Task ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch) + { + var data = await BuildCandidateDataAsync(batch); + if (!data.HasCommittedScenes) + { + return new StoryIntelligenceAssetAutoResolutionResult(0, 0, 0); + } + + var existingIndex = await BuildAssetIndexAsync(batch.ProjectID); + var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID); + var lookupData = await assets.GetLookupsAsync(batch.ProjectID); + var resolvedCandidates = 0; + var sceneEventsCreated = 0; + var ownershipLinksCreated = 0; + + foreach (var candidate in data.Candidates.Where(candidate => IsAutoResolvableKnownIdentity(candidate, existingIndex))) + { + var assetId = candidate.ExistingAssetID!.Value; + var linked = await LinkSceneAssetEventsAsync(candidate, assetId, lookupData, characterIndex); + sceneEventsCreated += linked.SceneEventsCreated; + ownershipLinksCreated += linked.OwnershipLinksCreated; + + foreach (var alias in candidate.Aliases) + { + await TryAddAliasAsync(assetId, alias, candidate.ExistingAssetName ?? candidate.DisplayName); + } + + resolvedCandidates++; + AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.LinkExisting, candidate.DisplayName, assetId, true); + } + + if (resolvedCandidates > 0) + { + logger.LogInformation( + "Automatically resolved Story Intelligence asset candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneEventsCreated={SceneEventsCreated} OwnershipLinksCreated={OwnershipLinksCreated}", + batch.BatchID, + batch.ProjectID, + batch.BookID, + resolvedCandidates, + sceneEventsCreated, + ownershipLinksCreated); + } + + return new StoryIntelligenceAssetAutoResolutionResult(resolvedCandidates, sceneEventsCreated, ownershipLinksCreated); + } + public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); @@ -118,7 +167,7 @@ public sealed class StoryIntelligenceAssetImportService( }; } - return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one asset to create, link or ignore." }; + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one asset to create, match, or ignore." }; } var existingIndex = await BuildAssetIndexAsync(batch.ProjectID); @@ -147,7 +196,7 @@ public sealed class StoryIntelligenceAssetImportService( continue; } - if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)) + if (IsPendingSameAssetChoice(choice)) { continue; } @@ -156,10 +205,16 @@ public sealed class StoryIntelligenceAssetImportService( var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; var linkExisting = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); var matchedExistingId = linkExisting - ? candidate.ExistingAssetID + ? choice.ExistingAssetID + ?? candidate.ExistingAssetID ?? existingIndex.Find(importName)?.StoryAssetID ?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID : null; + if (linkExisting && !matchedExistingId.HasValue) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose an asset for each Same asset as decision." }; + } + var assetId = matchedExistingId; if (!assetId.HasValue) @@ -195,17 +250,41 @@ public sealed class StoryIntelligenceAssetImportService( foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice) - || !string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)) + || !IsPendingSameAssetChoice(choice)) { continue; } var targetKey = Clean(choice.AliasTargetKey); + if (choice.ExistingAssetID is > 0) + { + var targetAsset = await assets.GetAssetAsync(choice.ExistingAssetID.Value); + if (targetAsset is not null) + { + if (await TryAddAliasAsync(targetAsset.StoryAssetID, candidate.DisplayName, targetAsset.AssetName)) + { + aliasesAdded++; + } + + foreach (var alias in candidate.Aliases) + { + if (await TryAddAliasAsync(targetAsset.StoryAssetID, alias, targetAsset.AssetName)) + { + aliasesAdded++; + } + } + + resolvedCandidateIds[candidate.Key] = targetAsset.StoryAssetID; + AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.LinkExisting, candidate.DisplayName, targetAsset.StoryAssetID, true); + continue; + } + } + if (string.IsNullOrWhiteSpace(targetKey) || string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase) || !choices.TryGetValue(targetKey, out var targetChoice) || string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase) - || string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) + || IsPendingSameAssetChoice(targetChoice) || !candidateByKey.TryGetValue(targetKey, out var targetCandidate) || !resolvedCandidateIds.TryGetValue(targetKey, out var targetAssetId)) { @@ -242,50 +321,17 @@ public sealed class StoryIntelligenceAssetImportService( continue; } - var assetId = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) - ? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) + var assetId = IsPendingSameAssetChoice(choice) + ? choice.ExistingAssetID ?? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) : resolvedCandidateIds.GetValueOrDefault(candidate.Key); if (assetId <= 0) { continue; } - foreach (var appearance in candidate.Appearances - .Where(appearance => !appearance.AlreadyLinked) - .GroupBy(appearance => appearance.SceneID) - .Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())) - { - await assets.SaveEventAsync(new AssetEvent - { - StoryAssetID = assetId, - SceneID = appearance.SceneID, - AssetEventTypeID = MatchAssetEventType(lookupData.AssetEventTypes, appearance.EventType), - ToStateID = MatchAssetState(lookupData.AssetStates, StateFromEventType(appearance.EventType)), - EventTitle = appearance.EventType, - EventDescription = appearance.Notes - }); - sceneEventsCreated++; - - var owner = Clean(appearance.OwnerOrHolder ?? candidate.OwnerOrHolder); - if (!string.IsNullOrWhiteSpace(owner) - && characterIndex.Find(owner) is CharacterMatch ownerMatch - && lookupData.AssetCustodyEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Has", StringComparison.OrdinalIgnoreCase)) is { } custodyType - && lookupData.CustodyRoles.FirstOrDefault(role => string.Equals(role.RoleName, "Has Custody", StringComparison.OrdinalIgnoreCase)) is { } role) - { - await assets.SaveCustodyEventAsync( - new AssetCustodyEvent - { - StoryAssetID = assetId, - SceneID = appearance.SceneID, - AssetCustodyEventTypeID = custodyType.AssetCustodyEventTypeID, - Description = $"Initial owner/holder from Story Intelligence: {owner}" - }, - owner, - [ownerMatch.CharacterID], - role.CustodyRoleID); - ownershipLinksCreated++; - } - } + var linked = await LinkSceneAssetEventsAsync(candidate, assetId, lookupData, characterIndex); + sceneEventsCreated += linked.SceneEventsCreated; + ownershipLinksCreated += linked.OwnershipLinksCreated; } batch.AssetStageComplete = completeReview; @@ -316,6 +362,54 @@ public sealed class StoryIntelligenceAssetImportService( }; } + private async Task LinkSceneAssetEventsAsync( + AssetCandidate candidate, + int assetId, + AssetLookupData lookupData, + CharacterIndex characterIndex) + { + var sceneEventsCreated = 0; + var ownershipLinksCreated = 0; + foreach (var appearance in candidate.Appearances + .Where(appearance => !appearance.AlreadyLinked) + .GroupBy(appearance => appearance.SceneID) + .Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())) + { + await assets.SaveEventAsync(new AssetEvent + { + StoryAssetID = assetId, + SceneID = appearance.SceneID, + AssetEventTypeID = MatchAssetEventType(lookupData.AssetEventTypes, appearance.EventType), + ToStateID = MatchAssetState(lookupData.AssetStates, StateFromEventType(appearance.EventType)), + EventTitle = appearance.EventType, + EventDescription = appearance.Notes + }); + sceneEventsCreated++; + + var owner = Clean(appearance.OwnerOrHolder ?? candidate.OwnerOrHolder); + if (!string.IsNullOrWhiteSpace(owner) + && characterIndex.Find(owner) is CharacterMatch ownerMatch + && lookupData.AssetCustodyEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Has", StringComparison.OrdinalIgnoreCase)) is { } custodyType + && lookupData.CustodyRoles.FirstOrDefault(role => string.Equals(role.RoleName, "Has Custody", StringComparison.OrdinalIgnoreCase)) is { } role) + { + await assets.SaveCustodyEventAsync( + new AssetCustodyEvent + { + StoryAssetID = assetId, + SceneID = appearance.SceneID, + AssetCustodyEventTypeID = custodyType.AssetCustodyEventTypeID, + Description = $"Initial owner/holder from Story Intelligence: {owner}" + }, + owner, + [ownerMatch.CharacterID], + role.CustodyRoleID); + ownershipLinksCreated++; + } + } + + return new AssetSceneLinkResult(sceneEventsCreated, ownershipLinksCreated); + } + private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) { var existingIndex = await BuildAssetIndexAsync(batch.ProjectID); @@ -681,6 +775,10 @@ public sealed class StoryIntelligenceAssetImportService( || sceneCount >= 2 && (ContainsStoryAssetSignal(signalText) || IsDistinctiveAssetName(candidate.DisplayName)); } + private static bool IsAutoResolvableKnownIdentity(AssetCandidate candidate, AssetIndex existingIndex) + => candidate.ExistingAssetID.HasValue + && existingIndex.Find(candidate.DisplayName)?.StoryAssetID == candidate.ExistingAssetID.Value; + private static bool ContainsStructuralSignificance(AssetCandidate candidate) => candidate.Appearances.Any(appearance => string.Equals(appearance.EventType, "Mentioned", StringComparison.OrdinalIgnoreCase) && IsStructuralSummaryEvidence(appearance.Notes) @@ -879,6 +977,11 @@ public sealed class StoryIntelligenceAssetImportService( }); } + private static bool IsPendingSameAssetChoice(StoryIntelligenceAssetImportChoiceForm choice) + => string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) + || (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(choice.AliasTargetKey)); + private static bool IsAssetEntity(string? entityType) => string.Equals(Clean(entityType), "Asset", StringComparison.OrdinalIgnoreCase) || string.Equals(Clean(entityType), "Object", StringComparison.OrdinalIgnoreCase); @@ -1066,19 +1169,35 @@ public sealed class StoryIntelligenceAssetImportService( private sealed class AssetIndex { - private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> byName = new(StringComparer.OrdinalIgnoreCase); public void Add(string? name, int storyAssetId, string assetName) { var key = CanonicalAssetKey(name ?? string.Empty); if (!string.IsNullOrWhiteSpace(key)) { - byName.TryAdd(key, new AssetMatch(storyAssetId, assetName)); + if (!byName.TryGetValue(key, out var matches)) + { + matches = []; + byName[key] = matches; + } + + if (matches.All(match => match.StoryAssetID != storyAssetId)) + { + matches.Add(new AssetMatch(storyAssetId, assetName)); + } } } public AssetMatch? Find(string name) - => byName.TryGetValue(CanonicalAssetKey(name), out var match) ? match : null; + { + if (!byName.TryGetValue(CanonicalAssetKey(name), out var matches)) + { + return null; + } + + return matches.Select(match => match.StoryAssetID).Distinct().Count() == 1 ? matches[0] : null; + } } private sealed class CharacterIndex @@ -1099,6 +1218,7 @@ public sealed class StoryIntelligenceAssetImportService( } private sealed record AssetMatch(int StoryAssetID, string AssetName); + private sealed record AssetSceneLinkResult(int SceneEventsCreated, int OwnershipLinksCreated); private sealed record CharacterMatch(int CharacterID, string CharacterName); private sealed class AssetCandidate(string key, string displayName, int? existingAssetId, string? existingAssetName) diff --git a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs index 1aacd18..b90251f 100644 --- a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs +++ b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs @@ -33,6 +33,7 @@ public sealed class StoryIntelligenceReviewCandidateService( IStoryIntelligenceKnowledgeImportService knowledgeImport, ICharacterRepository characters, ILocationRepository locations, + IAssetRepository assets, IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceReviewCandidateService { @@ -68,15 +69,28 @@ public sealed class StoryIntelligenceReviewCandidateService( { await PrepareLocationCandidatesAsync(batch); + var automaticAssets = await assetImport.ResolveAutomaticMatchesAsync(batch); + var assetReview = await assetImport.BuildReviewAsync(batch); await PersistAsync( batch, StoryIntelligenceReviewModules.Assets, - (await assetImport.BuildReviewAsync(batch)).Candidates, + 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, @@ -211,6 +225,9 @@ public sealed class StoryIntelligenceReviewCandidateService( public async Task GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize); + var allPendingPage = await LoadPageAsync(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, @@ -221,6 +238,7 @@ public sealed class StoryIntelligenceReviewCandidateService( PageSize = page.PageSize, TotalCandidateCount = page.TotalCount, PendingCandidateCount = page.PendingCount, + AssetTargetOptions = targetOptions, Candidates = page.Items }; } @@ -386,6 +404,63 @@ public sealed class StoryIntelligenceReviewCandidateService( private static string LocationTargetKey(string? name) => StoryIntelligenceEntityTextNormaliser.SafeKey(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty)); + private async Task> BuildAssetTargetOptionsAsync( + IReadOnlyList projectAssets, + IReadOnlyList pendingCandidates) + { + var canonicalKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var options = new List(); + 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 MarkAssetDecisionsAsync(int bookId, IEnumerable choices) { StoryIntelligenceReviewCandidateGeneration? generation = null; @@ -397,7 +472,8 @@ public sealed class StoryIntelligenceReviewCandidateService( Module = StoryIntelligenceReviewModules.Assets, StableKey = choice.Key, DecisionAction = choice.Action, - DecisionTargetKey = choice.AliasTargetKey + DecisionTargetKey = choice.AliasTargetKey, + DecisionCanonicalID = choice.ExistingAssetID }); } diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index a5ecb65..eb29bb6 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -553,11 +553,27 @@ public sealed class StoryIntelligenceAssetReviewViewModel public int PageSize { get; init; } = 25; public int TotalCandidateCount { get; init; } public int PendingCandidateCount { get; init; } + public int PageCount => PendingCandidateCount <= 0 ? 1 : (int)Math.Ceiling(PendingCandidateCount / (double)PageSize); + public int ShowingStart => PendingCandidateCount == 0 || Candidates.Count == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1; + public int ShowingEnd => ShowingStart == 0 ? 0 : Math.Min(PendingCandidateCount, ShowingStart + Candidates.Count - 1); public bool HasPreviousPage => PageNumber > 1; - public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount; + public bool HasNextPage => PageNumber < PageCount; + public IReadOnlyList AssetTargetOptions { get; init; } = []; public IReadOnlyList Candidates { get; init; } = []; } +public sealed class StoryIntelligenceAssetTargetOptionViewModel +{ + public string Value { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string GroupName { get; init; } = string.Empty; + public int? AssetID { get; init; } + public string? CandidateKey { get; init; } + public string? AliasSummary { get; init; } + public bool IsCanonical => AssetID.HasValue; + public bool IsPendingCandidate => !string.IsNullOrWhiteSpace(CandidateKey); +} + public sealed class StoryIntelligenceAssetReviewCandidateViewModel { public string Key { get; init; } = string.Empty; @@ -579,6 +595,7 @@ public sealed class StoryIntelligenceAssetReviewCandidateViewModel public sealed class StoryIntelligenceAssetImportForm { public Guid BatchID { get; set; } + public int PageNumber { get; set; } = 1; public List Assets { get; set; } = []; } @@ -588,6 +605,7 @@ public sealed class StoryIntelligenceAssetImportChoiceForm public string Action { get; set; } = StoryIntelligenceAssetImportActions.CreateNew; public string? ImportName { get; set; } public string? AliasTargetKey { get; set; } + public int? ExistingAssetID { get; set; } } public static class StoryIntelligenceAssetImportActions diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml index 2a3b037..5a4fbb3 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml @@ -1,9 +1,7 @@ @model StoryIntelligenceProgressViewModel @{ ViewData["Title"] = "Review assets"; - const int previewLimit = 25; - var assetCandidates = Model.AssetReview.Candidates.Take(previewLimit).ToList(); - var hiddenCandidateCount = Math.Max(0, Model.AssetReview.PendingCandidateCount - assetCandidates.Count); + var assetCandidates = Model.AssetReview.Candidates.ToList(); }
@@ -11,7 +9,7 @@

Story Intelligence

Review Assets

-

Review important object suggestions that need a create, link, alias, or ignore decision.

+

Review important object suggestions that need a create, same-asset, or ignore decision.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -41,24 +39,22 @@ else {
- @if (hiddenCandidateCount > 0) - { - Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending - } - - - + + Page @Model.AssetReview.PageNumber.ToString("N0") of @Model.AssetReview.PageCount.ToString("N0") + · Showing @Model.AssetReview.ShowingStart.ToString("N0")-@Model.AssetReview.ShowingEnd.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending +
+
@for (var i = 0; i < assetCandidates.Count; i++) { var candidate = assetCandidates[i]; -
+
@candidate.AssetName @@ -95,66 +91,81 @@ @if (candidate.IsExistingMatch) {
- Possible existing asset -

@candidate.AssetName may already be @candidate.ExistingAssetName. Choose whether to link them or create a separate asset.

+ Existing asset matched +

@candidate.AssetName is linked to @candidate.ExistingAssetName.

} -
- Decision - - - - -
+ @if (candidate.IsExistingMatch) + { + + + } + else + { +
+ Decision + + + +
-
- - -
+
+ + +
- + + }
}
- @if (hiddenCandidateCount > 0) - { - Save this batch to continue with @hiddenCandidateCount.ToString("N0") more asset decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). - } - Back to Review Centre + + Page @Model.AssetReview.PageNumber.ToString("N0") of @Model.AssetReview.PageCount.ToString("N0") + · Showing @Model.AssetReview.ShowingStart.ToString("N0")-@Model.AssetReview.ShowingEnd.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending + @if (Model.AssetReview.HasPreviousPage) { - Previous + } @if (Model.AssetReview.HasNextPage) { - Next + + } + else + { + } -
} @@ -177,49 +188,29 @@ } }; const actionFor = (card) => card.querySelector("[data-asset-action]:checked")?.value || "@StoryIntelligenceAssetImportActions.CreateNew"; - const isCanonicalTarget = (card) => { - const action = actionFor(card); - return action !== "@StoryIntelligenceAssetImportActions.Ignore" && action !== "@StoryIntelligenceAssetImportActions.Alias"; + const applyUnifiedTarget = (card) => { + const selector = card.querySelector("[data-asset-unified-target]"); + const existing = card.querySelector("[data-asset-existing-target]"); + const pending = card.querySelector("[data-asset-alias-target]"); + const value = selector?.value || ""; + if (existing && selector) existing.value = value.startsWith("existing:") ? value.substring("existing:".length) : ""; + if (pending && selector) pending.value = value.startsWith("candidate:") ? value.substring("candidate:".length) : ""; }; const updateAliasTargets = () => { - const allCards = cards(); - for (const card of allCards) { - const body = card.querySelector("[data-asset-key]"); - const ownKey = body?.getAttribute("data-asset-key") || ""; - const select = card.querySelector("[data-asset-alias-target]"); - if (!select) continue; - - const previous = select.value; - for (const option of Array.from(select.options)) { - if (!option.value) continue; - const targetCard = allCards.find(item => item.querySelector("[data-asset-key]")?.getAttribute("data-asset-key") === option.value); - option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard); - } - if (select.selectedOptions[0]?.disabled) { - select.value = ""; - } else { - select.value = previous; - } - } + cards().forEach(applyUnifiedTarget); }; const updateCard = (card) => { const action = actionFor(card); const namePanel = card.querySelector("[data-asset-import-name-panel]"); - const aliasPanel = card.querySelector("[data-asset-alias-panel]"); - if (namePanel) namePanel.hidden = action === "@StoryIntelligenceAssetImportActions.Ignore" || action === "@StoryIntelligenceAssetImportActions.Alias"; - if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceAssetImportActions.Alias"; + const samePanel = card.querySelector("[data-asset-same-panel]"); + applyUnifiedTarget(card); + if (namePanel) namePanel.hidden = action === "@StoryIntelligenceAssetImportActions.Ignore" || action === "@StoryIntelligenceAssetImportActions.LinkExisting"; + if (samePanel) samePanel.hidden = action !== "@StoryIntelligenceAssetImportActions.LinkExisting"; updateAliasTargets(); }; document.querySelectorAll("[data-asset-bulk]").forEach(button => { button.addEventListener("click", () => { const action = button.getAttribute("data-asset-bulk"); - if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.CreateNew")); - if (action === "link-existing") cards().forEach(card => { - if (card.querySelector(`[data-asset-action][value="@StoryIntelligenceAssetImportActions.LinkExisting"]:not(:disabled)`)) { - setAction(card, "@StoryIntelligenceAssetImportActions.LinkExisting"); - } - }); - if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Ignore")); if (action === "expand") cards().forEach(card => card.open = true); if (action === "collapse") cards().forEach(card => card.open = false); }); @@ -227,6 +218,9 @@ form.querySelectorAll("[data-asset-action]").forEach(input => { input.addEventListener("change", () => updateCard(input.closest("[data-asset-card]"))); }); + form.querySelectorAll("[data-asset-unified-target]").forEach(select => { + select.addEventListener("change", () => cards().forEach(updateCard)); + }); cards().forEach(updateCard); })(); @@ -235,4 +229,10 @@ @functions { private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; + private static string TargetLabel(StoryIntelligenceAssetTargetOptionViewModel target) + => string.IsNullOrWhiteSpace(target.AliasSummary) ? target.DisplayName : $"{target.DisplayName} ({target.AliasSummary})"; + + private static bool IsSelfTarget(StoryIntelligenceAssetTargetOptionViewModel target, StoryIntelligenceAssetReviewCandidateViewModel candidate) + => (target.AssetID.HasValue && candidate.ExistingAssetID == target.AssetID) + || (!string.IsNullOrWhiteSpace(target.CandidateKey) && string.Equals(target.CandidateKey, candidate.Key, StringComparison.OrdinalIgnoreCase)); }