Apply asset review identity rules
This commit is contained in:
parent
577ec581df
commit
64225dde1a
@ -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.");
|
||||||
|
|||||||
@ -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")]
|
||||||
|
|||||||
@ -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,50 +321,17 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var appearance in candidate.Appearances
|
var linked = await LinkSceneAssetEventsAsync(candidate, assetId, lookupData, characterIndex);
|
||||||
.Where(appearance => !appearance.AlreadyLinked)
|
sceneEventsCreated += linked.SceneEventsCreated;
|
||||||
.GroupBy(appearance => appearance.SceneID)
|
ownershipLinksCreated += linked.OwnershipLinksCreated;
|
||||||
.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++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
batch.AssetStageComplete = completeReview;
|
batch.AssetStageComplete = completeReview;
|
||||||
@ -316,6 +362,54 @@ public sealed class StoryIntelligenceAssetImportService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
.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<AssetCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
|
private async Task<AssetCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
|
var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
· 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,66 +91,81 @@
|
|||||||
@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>
|
||||||
}
|
}
|
||||||
|
|
||||||
<fieldset class="story-character-actions">
|
@if (candidate.IsExistingMatch)
|
||||||
<legend>Decision</legend>
|
{
|
||||||
<label>
|
<input type="hidden" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.LinkExisting" data-asset-action />
|
||||||
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.CreateNew" checked data-asset-action />
|
<input type="hidden" name="Assets[@i].ExistingAssetID" value="@candidate.ExistingAssetID" />
|
||||||
Create new asset
|
}
|
||||||
</label>
|
else
|
||||||
<label>
|
{
|
||||||
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.LinkExisting" data-asset-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
|
<fieldset class="story-character-actions">
|
||||||
Link existing asset
|
<legend>Decision</legend>
|
||||||
</label>
|
<label>
|
||||||
<label>
|
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.CreateNew" checked data-asset-action />
|
||||||
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Alias" data-asset-action />
|
Create new asset
|
||||||
Alias of another asset
|
</label>
|
||||||
</label>
|
<label>
|
||||||
<label>
|
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.LinkExisting" data-asset-action />
|
||||||
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Ignore" data-asset-action />
|
Same asset as
|
||||||
Ignore
|
</label>
|
||||||
</label>
|
<label>
|
||||||
</fieldset>
|
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Ignore" data-asset-action />
|
||||||
|
Ignore
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div data-asset-import-name-panel>
|
<div data-asset-import-name-panel>
|
||||||
<label class="form-label" for="asset-import-name-@i">Import name</label>
|
<label class="form-label" for="asset-import-name-@i">Import name</label>
|
||||||
<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))
|
||||||
<option value="@target.Key">@target.AssetName@(target.IsExistingMatch ? $" -> {target.ExistingAssetName}" : string.Empty)</option>
|
.GroupBy(target => target.GroupName))
|
||||||
}
|
{
|
||||||
</select>
|
<optgroup label="@group.Key">
|
||||||
</div>
|
@foreach (var target in group)
|
||||||
|
{
|
||||||
|
<option value="@target.Value">@TargetLabel(target)</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
</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>
|
· 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));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user