Apply asset review identity rules

This commit is contained in:
Nick Beckley 2026-09-02 19:14:32 +00:00
parent 577ec581df
commit 64225dde1a
6 changed files with 438 additions and 143 deletions

View File

@ -62,6 +62,7 @@ var tests = new (string Name, Action Test)[]
("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership), ("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership),
("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns), ("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns),
("Asset significance is generic and not fixture-specific", AssetSignificanceIsGenericAndNotFixtureSpecific), ("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 map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance), ("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance),
("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder), ("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder),
@ -1059,11 +1060,14 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering()
foreach (var viewName in heavyViews) foreach (var viewName in heavyViews)
{ {
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName)); 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")); 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."); var module = string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal) ? "Location" : "Asset";
Assert(view.Contains("var locationCandidates = Model.LocationReview.Candidates.ToList();", StringComparison.Ordinal), $"{viewName} should render only the persisted candidate page."); 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 else
{ {
@ -2988,6 +2992,66 @@ static void AssetSignificanceIsGenericAndNotFixtureSpecific()
Assert(!service.Contains("\"tr6\"", StringComparison.OrdinalIgnoreCase), "Production asset logic must not special-case TR6."); 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<StoryIntelligenceAssetAutoResolutionResult> 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<string, List<AssetMatch>> 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("<input type=\"hidden\" name=\"Assets[@i].Action\" value=\"@StoryIntelligenceAssetImportActions.LinkExisting\"", StringComparison.Ordinal),
"Known existing Asset matches that reach review should be fixed to LinkExisting, not CreateNew.");
Assert(view.Contains("Page @Model.AssetReview.PageNumber", StringComparison.Ordinal)
&& view.Contains("Showing @Model.AssetReview.ShowingStart", StringComparison.Ordinal),
"Asset review should show page and pending range counts.");
Assert(view.Contains("name=\"intent\" value=\"previous\"", StringComparison.Ordinal)
&& view.Contains("name=\"intent\" value=\"next\"", StringComparison.Ordinal),
"Asset review Previous and Next should submit current-page decisions before navigation.");
Assert(view.Contains("if (Model.AssetReview.HasNextPage)", StringComparison.Ordinal)
&& view.Contains("name=\"intent\" value=\"save\"", StringComparison.Ordinal),
"Asset review should show Next before the last page and Save only on the last page.");
Assert(controller.Contains("ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form, string intent = \"save\")", StringComparison.Ordinal),
"Asset POST should accept a save/navigation intent.");
Assert(controller.Contains("pendingCount <= 0", StringComparison.Ordinal)
&& controller.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal),
"Asset review should return to Review Centre only after module-wide pending reaches zero.");
Assert(controller.Contains("RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID, page = targetPage })", StringComparison.Ordinal),
"Asset review should stay in-module while pending candidates remain.");
}
static void RelationshipSignalsMapToBroadTypes() static void RelationshipSignalsMapToBroadTypes()
{ {
Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend."); Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend.");

View File

@ -579,7 +579,7 @@ public sealed class OnboardingController(
[HttpPost("story-intelligence/assets")] [HttpPost("story-intelligence/assets")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form) public async Task<IActionResult> ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form, string intent = "save")
{ {
var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form); var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form);
if (progress is null) if (progress is null)
@ -588,9 +588,26 @@ public sealed class OnboardingController(
} }
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success if (!result.Success)
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) {
: RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); 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")] [HttpPost("story-intelligence/relationships")]

View File

@ -8,10 +8,13 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceAssetImportService public interface IStoryIntelligenceAssetImportService
{ {
Task<StoryIntelligenceAssetAutoResolutionResult> ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview);
} }
public sealed record StoryIntelligenceAssetAutoResolutionResult(int CandidatesResolved, int SceneEventsCreated, int OwnershipLinksCreated);
public sealed class StoryIntelligenceAssetImportService( public sealed class StoryIntelligenceAssetImportService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IAssetRepository assets, IAssetRepository assets,
@ -53,6 +56,52 @@ public sealed class StoryIntelligenceAssetImportService(
"child", "children", "aunt", "uncle", "wife", "husband", "partner", "friend", "neighbour", "neighbor", "child", "children", "aunt", "uncle", "wife", "husband", "partner", "friend", "neighbour", "neighbor",
"teacher", "student", "examiner", "doctor", "nurse"); "teacher", "student", "examiner", "doctor", "nurse");
public async Task<StoryIntelligenceAssetAutoResolutionResult> 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<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); 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); var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
@ -147,7 +196,7 @@ public sealed class StoryIntelligenceAssetImportService(
continue; continue;
} }
if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)) if (IsPendingSameAssetChoice(choice))
{ {
continue; continue;
} }
@ -156,10 +205,16 @@ public sealed class StoryIntelligenceAssetImportService(
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var linkExisting = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); var linkExisting = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = linkExisting var matchedExistingId = linkExisting
? candidate.ExistingAssetID ? choice.ExistingAssetID
?? candidate.ExistingAssetID
?? existingIndex.Find(importName)?.StoryAssetID ?? existingIndex.Find(importName)?.StoryAssetID
?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID ?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID
: null; : null;
if (linkExisting && !matchedExistingId.HasValue)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose an asset for each Same asset as decision." };
}
var assetId = matchedExistingId; var assetId = matchedExistingId;
if (!assetId.HasValue) if (!assetId.HasValue)
@ -195,17 +250,41 @@ public sealed class StoryIntelligenceAssetImportService(
foreach (var candidate in data.Candidates) foreach (var candidate in data.Candidates)
{ {
if (!choices.TryGetValue(candidate.Key, out var choice) if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)) || !IsPendingSameAssetChoice(choice))
{ {
continue; continue;
} }
var targetKey = Clean(choice.AliasTargetKey); 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) if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase) || string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice) || !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase) || string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) || IsPendingSameAssetChoice(targetChoice)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate) || !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetAssetId)) || !resolvedCandidateIds.TryGetValue(targetKey, out var targetAssetId))
{ {
@ -242,14 +321,55 @@ public sealed class StoryIntelligenceAssetImportService(
continue; continue;
} }
var assetId = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) var assetId = IsPendingSameAssetChoice(choice)
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) ? choice.ExistingAssetID ?? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
: resolvedCandidateIds.GetValueOrDefault(candidate.Key); : resolvedCandidateIds.GetValueOrDefault(candidate.Key);
if (assetId <= 0) if (assetId <= 0)
{ {
continue; continue;
} }
var linked = await LinkSceneAssetEventsAsync(candidate, assetId, lookupData, characterIndex);
sceneEventsCreated += linked.SceneEventsCreated;
ownershipLinksCreated += linked.OwnershipLinksCreated;
}
batch.AssetStageComplete = completeReview;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult
{
AssetsCreated = created,
AssetsLinked = linkedExisting,
AssetAliasesAdded = aliasesAdded,
AssetsIgnored = ignored,
SceneAssetEventsCreated = sceneEventsCreated,
OwnershipLinksCreated = ownershipLinksCreated
};
logger.LogInformation(
"Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}",
batch.BatchID,
created,
linkedExisting,
aliasesAdded,
ignored,
sceneEventsCreated,
ownershipLinksCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = sceneEventsCreated,
Message = $"Assets imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneEventsCreated:N0} scene asset event(s) created."
};
}
private async Task<AssetSceneLinkResult> LinkSceneAssetEventsAsync(
AssetCandidate candidate,
int assetId,
AssetLookupData lookupData,
CharacterIndex characterIndex)
{
var sceneEventsCreated = 0;
var ownershipLinksCreated = 0;
foreach (var appearance in candidate.Appearances foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked) .Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID) .GroupBy(appearance => appearance.SceneID)
@ -286,34 +406,8 @@ public sealed class StoryIntelligenceAssetImportService(
ownershipLinksCreated++; ownershipLinksCreated++;
} }
} }
}
batch.AssetStageComplete = completeReview; return new AssetSceneLinkResult(sceneEventsCreated, ownershipLinksCreated);
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult
{
AssetsCreated = created,
AssetsLinked = linkedExisting,
AssetAliasesAdded = aliasesAdded,
AssetsIgnored = ignored,
SceneAssetEventsCreated = sceneEventsCreated,
OwnershipLinksCreated = ownershipLinksCreated
};
logger.LogInformation(
"Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}",
batch.BatchID,
created,
linkedExisting,
aliasesAdded,
ignored,
sceneEventsCreated,
ownershipLinksCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = sceneEventsCreated,
Message = $"Assets imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneEventsCreated:N0} scene asset event(s) created."
};
} }
private async Task<AssetCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) private async Task<AssetCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
@ -681,6 +775,10 @@ public sealed class StoryIntelligenceAssetImportService(
|| sceneCount >= 2 && (ContainsStoryAssetSignal(signalText) || IsDistinctiveAssetName(candidate.DisplayName)); || 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) private static bool ContainsStructuralSignificance(AssetCandidate candidate)
=> candidate.Appearances.Any(appearance => string.Equals(appearance.EventType, "Mentioned", StringComparison.OrdinalIgnoreCase) => candidate.Appearances.Any(appearance => string.Equals(appearance.EventType, "Mentioned", StringComparison.OrdinalIgnoreCase)
&& IsStructuralSummaryEvidence(appearance.Notes) && 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) private static bool IsAssetEntity(string? entityType)
=> string.Equals(Clean(entityType), "Asset", StringComparison.OrdinalIgnoreCase) => string.Equals(Clean(entityType), "Asset", StringComparison.OrdinalIgnoreCase)
|| string.Equals(Clean(entityType), "Object", StringComparison.OrdinalIgnoreCase); || string.Equals(Clean(entityType), "Object", StringComparison.OrdinalIgnoreCase);
@ -1066,19 +1169,35 @@ public sealed class StoryIntelligenceAssetImportService(
private sealed class AssetIndex private sealed class AssetIndex
{ {
private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, List<AssetMatch>> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int storyAssetId, string assetName) public void Add(string? name, int storyAssetId, string assetName)
{ {
var key = CanonicalAssetKey(name ?? string.Empty); var key = CanonicalAssetKey(name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(key)) 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) 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 private sealed class CharacterIndex
@ -1099,6 +1218,7 @@ public sealed class StoryIntelligenceAssetImportService(
} }
private sealed record AssetMatch(int StoryAssetID, string AssetName); 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 record CharacterMatch(int CharacterID, string CharacterName);
private sealed class AssetCandidate(string key, string displayName, int? existingAssetId, string? existingAssetName) private sealed class AssetCandidate(string key, string displayName, int? existingAssetId, string? existingAssetName)

View File

@ -33,6 +33,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
IStoryIntelligenceKnowledgeImportService knowledgeImport, IStoryIntelligenceKnowledgeImportService knowledgeImport,
ICharacterRepository characters, ICharacterRepository characters,
ILocationRepository locations, ILocationRepository locations,
IAssetRepository assets,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService
{ {
@ -68,15 +69,28 @@ public sealed class StoryIntelligenceReviewCandidateService(
{ {
await PrepareLocationCandidatesAsync(batch); await PrepareLocationCandidatesAsync(batch);
var automaticAssets = await assetImport.ResolveAutomaticMatchesAsync(batch);
var assetReview = await assetImport.BuildReviewAsync(batch);
await PersistAsync( await PersistAsync(
batch, batch,
StoryIntelligenceReviewModules.Assets, StoryIntelligenceReviewModules.Assets,
(await assetImport.BuildReviewAsync(batch)).Candidates, assetReview.Candidates,
candidate => candidate.Key, candidate => candidate.Key,
candidate => candidate.AssetName, candidate => candidate.AssetName,
candidate => candidate.Category, candidate => candidate.Category,
candidate => candidate.ExistingAssetID, candidate => candidate.ExistingAssetID,
candidate => candidate.PossibleAliases.Take(5)); 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( await PersistAsync(
batch, batch,
@ -211,6 +225,9 @@ public sealed class StoryIntelligenceReviewCandidateService(
public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{ {
var page = await LoadPageAsync<StoryIntelligenceAssetReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize); var page = await LoadPageAsync<StoryIntelligenceAssetReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize);
var allPendingPage = await LoadPageAsync<StoryIntelligenceAssetReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Assets, 1, MaxPageSize);
var projectAssets = (await assets.ListAssetsAsync(batch.ProjectID)).ToList();
var targetOptions = await BuildAssetTargetOptionsAsync(projectAssets, allPendingPage.Items);
return new StoryIntelligenceAssetReviewViewModel return new StoryIntelligenceAssetReviewViewModel
{ {
HasCommittedScenes = true, HasCommittedScenes = true,
@ -221,6 +238,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
PageSize = page.PageSize, PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount, TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount, PendingCandidateCount = page.PendingCount,
AssetTargetOptions = targetOptions,
Candidates = page.Items Candidates = page.Items
}; };
} }
@ -386,6 +404,63 @@ public sealed class StoryIntelligenceReviewCandidateService(
private static string LocationTargetKey(string? name) private static string LocationTargetKey(string? name)
=> StoryIntelligenceEntityTextNormaliser.SafeKey(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty)); => StoryIntelligenceEntityTextNormaliser.SafeKey(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty));
private async Task<IReadOnlyList<StoryIntelligenceAssetTargetOptionViewModel>> BuildAssetTargetOptionsAsync(
IReadOnlyList<StoryAsset> projectAssets,
IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> pendingCandidates)
{
var canonicalKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var options = new List<StoryIntelligenceAssetTargetOptionViewModel>();
foreach (var asset in projectAssets)
{
var aliasNames = (await assets.ListAliasesAsync(asset.StoryAssetID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, asset.AssetName, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase)
.ToList();
canonicalKeys.Add(AssetTargetKey(asset.AssetName));
foreach (var alias in aliasNames)
{
canonicalKeys.Add(AssetTargetKey(alias));
}
options.Add(new StoryIntelligenceAssetTargetOptionViewModel
{
Value = $"existing:{asset.StoryAssetID}",
AssetID = asset.StoryAssetID,
DisplayName = asset.AssetName,
GroupName = "Existing assets",
AliasSummary = string.Join(", ", aliasNames.Take(4))
});
}
foreach (var candidate in pendingCandidates.Where(candidate => !candidate.ExistingAssetID.HasValue))
{
var key = AssetTargetKey(candidate.AssetName);
if (string.IsNullOrWhiteSpace(key) || canonicalKeys.Contains(key))
{
continue;
}
options.Add(new StoryIntelligenceAssetTargetOptionViewModel
{
Value = $"candidate:{candidate.Key}",
CandidateKey = candidate.Key,
DisplayName = candidate.AssetName,
GroupName = "Pending suggestions"
});
}
return options
.OrderBy(option => option.GroupName, StringComparer.Ordinal)
.ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase)
.ThenBy(option => option.Value, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string AssetTargetKey(string? name)
=> StoryIntelligenceEntityTextNormaliser.SafeKey(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty));
public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices) public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices)
{ {
StoryIntelligenceReviewCandidateGeneration? generation = null; StoryIntelligenceReviewCandidateGeneration? generation = null;
@ -397,7 +472,8 @@ public sealed class StoryIntelligenceReviewCandidateService(
Module = StoryIntelligenceReviewModules.Assets, Module = StoryIntelligenceReviewModules.Assets,
StableKey = choice.Key, StableKey = choice.Key,
DecisionAction = choice.Action, DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.ExistingAssetID
}); });
} }

View File

@ -553,11 +553,27 @@ public sealed class StoryIntelligenceAssetReviewViewModel
public int PageSize { get; init; } = 25; public int PageSize { get; init; } = 25;
public int TotalCandidateCount { get; init; } public int TotalCandidateCount { get; init; }
public int PendingCandidateCount { 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 HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount; public bool HasNextPage => PageNumber < PageCount;
public IReadOnlyList<StoryIntelligenceAssetTargetOptionViewModel> AssetTargetOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> 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 sealed class StoryIntelligenceAssetReviewCandidateViewModel
{ {
public string Key { get; init; } = string.Empty; public string Key { get; init; } = string.Empty;
@ -579,6 +595,7 @@ public sealed class StoryIntelligenceAssetReviewCandidateViewModel
public sealed class StoryIntelligenceAssetImportForm public sealed class StoryIntelligenceAssetImportForm
{ {
public Guid BatchID { get; set; } public Guid BatchID { get; set; }
public int PageNumber { get; set; } = 1;
public List<StoryIntelligenceAssetImportChoiceForm> Assets { get; set; } = []; public List<StoryIntelligenceAssetImportChoiceForm> Assets { get; set; } = [];
} }
@ -588,6 +605,7 @@ public sealed class StoryIntelligenceAssetImportChoiceForm
public string Action { get; set; } = StoryIntelligenceAssetImportActions.CreateNew; public string Action { get; set; } = StoryIntelligenceAssetImportActions.CreateNew;
public string? ImportName { get; set; } public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; } public string? AliasTargetKey { get; set; }
public int? ExistingAssetID { get; set; }
} }
public static class StoryIntelligenceAssetImportActions public static class StoryIntelligenceAssetImportActions

View File

@ -1,9 +1,7 @@
@model StoryIntelligenceProgressViewModel @model StoryIntelligenceProgressViewModel
@{ @{
ViewData["Title"] = "Review assets"; ViewData["Title"] = "Review assets";
const int previewLimit = 25; var assetCandidates = Model.AssetReview.Candidates.ToList();
var assetCandidates = Model.AssetReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.AssetReview.PendingCandidateCount - assetCandidates.Count);
} }
<section class="onboarding-shell" aria-labelledby="story-asset-title"> <section class="onboarding-shell" aria-labelledby="story-asset-title">
@ -11,7 +9,7 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence</p> <p class="eyebrow">Story Intelligence</p>
<h1 id="story-asset-title">Review Assets</h1> <h1 id="story-asset-title">Review Assets</h1>
<p>Review important object suggestions that need a create, link, alias, or ignore decision.</p> <p>Review important object suggestions that need a create, same-asset, or ignore decision.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -41,24 +39,22 @@
else else
{ {
<div class="story-review-card-actions"> <div class="story-review-card-actions">
@if (hiddenCandidateCount > 0) <span class="story-review-page-status">
{ Page @Model.AssetReview.PageNumber.ToString("N0") of @Model.AssetReview.PageCount.ToString("N0")
<span class="story-review-page-status">Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending</span> &middot; Showing @Model.AssetReview.ShowingStart.ToString("N0")-@Model.AssetReview.ShowingEnd.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending
} </span>
<button class="btn btn-outline-primary btn-sm" type="button" data-asset-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="expand">Expand all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="collapse">Collapse all</button>
</div> </div>
<form asp-action="ImportStoryIntelligenceAssets" method="post" data-asset-review-form data-story-review-form> <form asp-action="ImportStoryIntelligenceAssets" method="post" data-asset-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<input type="hidden" name="PageNumber" value="@Model.AssetReview.PageNumber" />
<div class="story-character-card-grid"> <div class="story-character-card-grid">
@for (var i = 0; i < assetCandidates.Count; i++) @for (var i = 0; i < assetCandidates.Count; i++)
{ {
var candidate = assetCandidates[i]; var candidate = assetCandidates[i];
<details class="story-character-card" open data-asset-card> <details class="story-character-card" open data-asset-card data-asset-existing-match="@candidate.IsExistingMatch.ToString().ToLowerInvariant()">
<summary> <summary>
<span> <span>
<strong>@candidate.AssetName</strong> <strong>@candidate.AssetName</strong>
@ -95,11 +91,18 @@
@if (candidate.IsExistingMatch) @if (candidate.IsExistingMatch)
{ {
<div class="story-review-note"> <div class="story-review-note">
<strong>Possible existing asset</strong> <strong>Existing asset matched</strong>
<p>@candidate.AssetName may already be @candidate.ExistingAssetName. Choose whether to link them or create a separate asset.</p> <p>@candidate.AssetName is linked to @candidate.ExistingAssetName.</p>
</div> </div>
} }
@if (candidate.IsExistingMatch)
{
<input type="hidden" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.LinkExisting" data-asset-action />
<input type="hidden" name="Assets[@i].ExistingAssetID" value="@candidate.ExistingAssetID" />
}
else
{
<fieldset class="story-character-actions"> <fieldset class="story-character-actions">
<legend>Decision</legend> <legend>Decision</legend>
<label> <label>
@ -107,12 +110,8 @@
Create new asset Create new asset
</label> </label>
<label> <label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.LinkExisting" data-asset-action @(candidate.IsExistingMatch ? string.Empty : "disabled") /> <input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.LinkExisting" data-asset-action />
Link existing asset Same asset as
</label>
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Alias" data-asset-action />
Alias of another asset
</label> </label>
<label> <label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Ignore" data-asset-action /> <input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Ignore" data-asset-action />
@ -125,36 +124,48 @@
<input id="asset-import-name-@i" class="form-control" name="Assets[@i].ImportName" value="@candidate.ImportName" /> <input id="asset-import-name-@i" class="form-control" name="Assets[@i].ImportName" value="@candidate.ImportName" />
</div> </div>
<div data-asset-alias-panel hidden> <div data-asset-same-panel hidden>
<label class="form-label" for="asset-alias-target-@i">Alias/variant target</label> <label class="form-label" for="asset-same-target-@i">Same asset as</label>
<select id="asset-alias-target-@i" class="form-select" name="Assets[@i].AliasTargetKey" data-asset-alias-target> <select id="asset-same-target-@i" class="form-select" data-asset-unified-target>
<option value="">Choose asset...</option> <option value="">Choose asset...</option>
@foreach (var target in assetCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase))) @foreach (var group in Model.AssetReview.AssetTargetOptions
.Where(target => !IsSelfTarget(target, candidate))
.GroupBy(target => target.GroupName))
{ {
<option value="@target.Key">@target.AssetName@(target.IsExistingMatch ? $" -> {target.ExistingAssetName}" : string.Empty)</option> <optgroup label="@group.Key">
@foreach (var target in group)
{
<option value="@target.Value">@TargetLabel(target)</option>
}
</optgroup>
} }
</select> </select>
<input type="hidden" name="Assets[@i].ExistingAssetID" data-asset-existing-target />
<input type="hidden" name="Assets[@i].AliasTargetKey" data-asset-alias-target />
</div> </div>
}
</div> </div>
</details> </details>
} }
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
@if (hiddenCandidateCount > 0) <span class="text-muted">
{ Page @Model.AssetReview.PageNumber.ToString("N0") of @Model.AssetReview.PageCount.ToString("N0")
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more asset decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span> &middot; Showing @Model.AssetReview.ShowingStart.ToString("N0")-@Model.AssetReview.ShowingEnd.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending
} </span>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
@if (Model.AssetReview.HasPreviousPage) @if (Model.AssetReview.HasPreviousPage)
{ {
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.AssetReview.PageNumber - 1)">Previous</a> <button class="btn btn-outline-secondary" type="submit" name="intent" value="previous" data-busy-label="Saving page...">Previous</button>
} }
@if (Model.AssetReview.HasNextPage) @if (Model.AssetReview.HasNextPage)
{ {
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.AssetReview.PageNumber + 1)">Next</a> <button class="btn btn-primary" type="submit" name="intent" value="next" data-busy-label="Saving page...">Next</button>
}
else
{
<button class="btn btn-primary" type="submit" name="intent" value="save" data-busy-label="Saving assets...">Save asset decisions</button>
} }
<button class="btn btn-primary" type="submit" data-busy-label="Saving assets...">Save asset decisions</button>
</div> </div>
</form> </form>
} }
@ -177,49 +188,29 @@
} }
}; };
const actionFor = (card) => card.querySelector("[data-asset-action]:checked")?.value || "@StoryIntelligenceAssetImportActions.CreateNew"; const actionFor = (card) => card.querySelector("[data-asset-action]:checked")?.value || "@StoryIntelligenceAssetImportActions.CreateNew";
const isCanonicalTarget = (card) => { const applyUnifiedTarget = (card) => {
const action = actionFor(card); const selector = card.querySelector("[data-asset-unified-target]");
return action !== "@StoryIntelligenceAssetImportActions.Ignore" && action !== "@StoryIntelligenceAssetImportActions.Alias"; 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 updateAliasTargets = () => {
const allCards = cards(); cards().forEach(applyUnifiedTarget);
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;
}
}
}; };
const updateCard = (card) => { const updateCard = (card) => {
const action = actionFor(card); const action = actionFor(card);
const namePanel = card.querySelector("[data-asset-import-name-panel]"); const namePanel = card.querySelector("[data-asset-import-name-panel]");
const aliasPanel = card.querySelector("[data-asset-alias-panel]"); const samePanel = card.querySelector("[data-asset-same-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceAssetImportActions.Ignore" || action === "@StoryIntelligenceAssetImportActions.Alias"; applyUnifiedTarget(card);
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceAssetImportActions.Alias"; if (namePanel) namePanel.hidden = action === "@StoryIntelligenceAssetImportActions.Ignore" || action === "@StoryIntelligenceAssetImportActions.LinkExisting";
if (samePanel) samePanel.hidden = action !== "@StoryIntelligenceAssetImportActions.LinkExisting";
updateAliasTargets(); updateAliasTargets();
}; };
document.querySelectorAll("[data-asset-bulk]").forEach(button => { document.querySelectorAll("[data-asset-bulk]").forEach(button => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const action = button.getAttribute("data-asset-bulk"); 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 === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false); if (action === "collapse") cards().forEach(card => card.open = false);
}); });
@ -227,6 +218,9 @@
form.querySelectorAll("[data-asset-action]").forEach(input => { form.querySelectorAll("[data-asset-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-asset-card]"))); 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); cards().forEach(updateCard);
})(); })();
</script> </script>
@ -235,4 +229,10 @@
@functions { @functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; 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));
} }