diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index f19acaf..2bf90e6 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -48,6 +48,7 @@ var tests = new (string Name, Action Test)[] ("Location contextual names use POV and structural setting", LocationContextualNamesUsePovAndStructuralSetting), ("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals), ("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants), + ("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations), ("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets), ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), ("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), @@ -2622,6 +2623,54 @@ static void LocationCandidateConsolidationHandlesGenericReviewVariants() Assert(!IsMergedLocationPhrase("Fox and Lantern"), "Generic named compounds should not need a production special-case exception."); } +static void LocationCandidateIdentityIsParentAwareForGenericSubLocations() +{ + var maggieBedroom = ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("Bedroom")); + var bethBedroom = ParentAwareLocationCandidateKey("beths-house", LocationCanonicalKey("Bedroom")); + var unknownBedroom = ParentAwareLocationCandidateKey(null, LocationCanonicalKey("Bedroom")); + var maggieKitchen = ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("Kitchen")); + + Assert(maggieBedroom != bethBedroom, "Maggie's house Bedroom and Beth's house Bedroom should remain separate candidates."); + Assert(maggieBedroom == ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("The bedrooms")), "Same parent and same leaf variants should consolidate."); + Assert(unknownBedroom != maggieBedroom, "Unknown-parent Bedroom must not merge into known-parent Bedroom."); + Assert(maggieBedroom != maggieKitchen, "Same parent but different leaf locations should remain separate."); + + Assert(ExplicitLocationLeaf("Zoe's bedroom") == "bedroom", "Explicit Zoe bedroom reference should keep Bedroom as leaf."); + Assert(InferExplicitLocationParent("Zoe's bedroom", "bedroom") == "Zoe's home", "Explicit Zoe bedroom reference should infer Zoe's home as parent."); + Assert(InferExplicitLocationParent("the bathroom at Beth's", "bathroom") == "Beth's home", "Bathroom at Beth's should infer Beth's home as parent."); + Assert(InferExplicitLocationParent("Mrs Patterson's kitchen", "kitchen") == "Mrs Patterson's home", "Possessive kitchen should infer an owned parent."); + Assert(InferExplicitLocationParent("bathroom to Maggie's bedroom", "bedroom") is null, "Route text before an owned bedroom should not become part of the owner."); + Assert(ParentFromOwnedLocationLeaf("Maggie's bedroom") == "Maggie's home", "Owned bedroom should expose a parent identity."); + Assert(ExtractParentFromCompositeLocationSetting("Maggie's house - living room and kitchen, late night") == "Maggie's house", "Scene parent should be inherited from a composite structural setting."); + Assert(ExtractParentFromCompositeLocationSetting("Cock Hill Lane; Maggie's home and bathroom") == "Maggie's home", "Owned scene container should beat broader route/location fragments."); + Assert(ExtractParentFromCompositeLocationSetting("Cock Hill Lane; bathroom") is null, "Street-level locations should not become parents for generic rooms."); + + var root = PlotLineRoot(); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs")); + var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml")); + var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); + var persisted = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs")); + + Assert(service.Contains("ParentAwareCandidateKey(parentContext.ParentIdentityKey, identity.Key)", StringComparison.Ordinal), "Location consolidation should use parent plus leaf identity."); + Assert(service.Contains("ResolveParentContext(cleanName, identity.DisplayName, genericRoomType, parentLocationHint, sceneParentContext, existingIndex)", StringComparison.Ordinal), "Location preparation should infer parent context before keying."); + Assert(service.Contains("InferSceneParentContext(importedScene, parsed)", StringComparison.Ordinal), "Scene context should be available for inherited generic child locations."); + Assert(service.Contains("EnsureParentCandidate(groups, existingIndex, importedScene, parsed, parentContext)", StringComparison.Ordinal), "Unresolved parents should be preserved as parent candidates."); + Assert(service.Contains("candidate.ProposedParentCandidateKey = parentKey", StringComparison.Ordinal), "Child candidates should retain durable parent-candidate linkage."); + Assert(service.Contains("return await MaterialiseLocationCandidateAsync(parentCandidate", StringComparison.Ordinal), "Child materialisation should create/link unresolved parent candidates first."); + Assert(service.Contains("existingIndex.Find(importName, parentId)", StringComparison.Ordinal), "Existing child matching should include parent location identity."); + Assert(service.Contains("string.Equals(selection, \"__none__\"", StringComparison.Ordinal), "Author clear-parent selection should be respected."); + Assert(service.Contains("ParentLocationID = parentId", StringComparison.Ordinal), "Accepted children should be saved under the resolved parent ID."); + + Assert(view.Contains("Proposed parent", StringComparison.Ordinal), "Location review should show proposed parent context."); + Assert(view.Contains("name=\"Locations[@i].ParentSelection\"", StringComparison.Ordinal), "Location review should let the author choose or clear parent context."); + Assert(view.Contains("Use proposed:", StringComparison.Ordinal), "Location review should allow accepting proposed parent context."); + Assert(view.Contains("Not determined", StringComparison.Ordinal), "Location review should allow unresolved parent context."); + Assert(viewModels.Contains("ProposedParentLocationID", StringComparison.Ordinal) + && viewModels.Contains("ProposedParentCandidateKey", StringComparison.Ordinal) + && viewModels.Contains("ParentIdentityKey", StringComparison.Ordinal), "Persisted Location candidate payload should include first-class parent identity."); + Assert(persisted.Contains("ParentLocationOptions = parentOptions", StringComparison.Ordinal), "Persisted Location GET should hydrate parent options without rebuilding Scene JSON."); +} + static void LocationReviewUxSupportsLargeCandidateSets() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); @@ -2671,6 +2720,51 @@ static string ResolveContextualLocationName(string name, string? genericRoomType return (string)method!.Invoke(null, [name, genericRoomType, scene])!; } +static string ParentAwareLocationCandidateKey(string? parentIdentityKey, string leafIdentityKey) +{ + var method = typeof(StoryIntelligenceLocationImportService).GetMethod( + "ParentAwareCandidateKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "ParentAwareCandidateKey was not found."); + return (string)method!.Invoke(null, [parentIdentityKey, leafIdentityKey])!; +} + +static string? ExplicitLocationLeaf(string name) +{ + var method = typeof(StoryIntelligenceLocationImportService).GetMethod( + "ExplicitLeaf", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "ExplicitLeaf was not found."); + return (string?)method!.Invoke(null, [name]); +} + +static string? InferExplicitLocationParent(string name, string leafName) +{ + var method = typeof(StoryIntelligenceLocationImportService).GetMethod( + "InferExplicitParent", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "InferExplicitParent was not found."); + return (string?)method!.Invoke(null, [name, leafName]); +} + +static string? ParentFromOwnedLocationLeaf(string name) +{ + var method = typeof(StoryIntelligenceLocationImportService).GetMethod( + "ParentFromOwnedLeaf", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "ParentFromOwnedLeaf was not found."); + return (string?)method!.Invoke(null, [name]); +} + +static string? ExtractParentFromCompositeLocationSetting(string name) +{ + var method = typeof(StoryIntelligenceLocationImportService).GetMethod( + "ExtractParentFromCompositeSetting", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "ExtractParentFromCompositeSetting was not found."); + return (string?)method!.Invoke(null, [name]); +} + static string LocationCanonicalKey(string name) { var method = typeof(StoryIntelligenceLocationImportService).GetMethod( diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index 784154b..21c4e7f 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -42,6 +42,11 @@ public sealed class StoryIntelligenceLocationImportService( "landing", "doorway", "pew", "altar", "floor", "entrance", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden", "staff room", "lift"); + private static readonly HashSet ParentableGenericLocations = CreateSet( + "room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs", + "landing", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden", + "staff room", "living room", "sitting room"); + private static readonly HashSet RepeatableGenericSettings = CreateSet( "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "staff room", "car park", "parking lot", "tunnel"); @@ -63,6 +68,8 @@ public sealed class StoryIntelligenceLocationImportService( private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex ControlCharacters = new(@"[\u0000-\u001F\u007F]+", RegexOptions.Compiled); private static readonly Regex PossessiveControlCharacter = new(@"(?<=\p{L})[\u0000-\u001F\u007F]+(?=s\b)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex PossessiveGenericLocation = new(@"^(?[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex GenericAtOwnedLocation = new(@"^(?:the\s+|a\s+|an\s+)?(?bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\s+(?:at|in|inside|within)\s+(?[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)(?:['’]s)?(?:\s+(?:house|home|flat|apartment))?$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex LeadingContextPhrase = new(@"^(?:inside|outside|at|in|within|near|around|beside|by)\s+(?:the\s+|a\s+|an\s+)?", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex TemporalSuffix = new(@",\s*(?:(?:early|late|later|same|next|following)\s+)?(?:morning|afternoon|evening|night|day|hours?|dawn|dusk|midday|midnight|christmas|boxing day|weekend|weekday|summer|winter|spring|autumn|fall|today|tonight|later)\b.*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled); @@ -84,21 +91,28 @@ public sealed class StoryIntelligenceLocationImportService( var data = await BuildCandidateDataAsync(batch); var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); + var parentOptions = ToParentOptions(await locations.ListByProjectAsync(batch.ProjectID)); return new StoryIntelligenceLocationReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + ParentLocationOptions = parentOptions, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel { Key = candidate.Key, LocationName = candidate.DisplayName, - ImportName = candidate.DisplayName, + LeafLocationName = candidate.LeafName, + ImportName = candidate.LeafName, Category = candidate.Category, AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), Confidence = ConfidenceBand(candidate.Confidence), ParentLocationHint = candidate.ParentLocationHint, + ParentIdentityKey = candidate.ParentIdentityKey, + ProposedParentLocationID = candidate.ProposedParentLocationID, + ProposedParentLocationName = candidate.ProposedParentLocationName, + ProposedParentCandidateKey = candidate.ProposedParentCandidateKey, ExampleFirstAppearance = candidate.FirstAppearanceNote, ExampleContext = candidate.Description, ExistingLocationID = candidate.ExistingLocationID, @@ -150,6 +164,109 @@ public sealed class StoryIntelligenceLocationImportService( var resolvedNames = new Dictionary(StringComparer.OrdinalIgnoreCase); var resolvedCandidateIds = new Dictionary(StringComparer.OrdinalIgnoreCase); var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase); + var materialising = new HashSet(StringComparer.OrdinalIgnoreCase); + + async Task ResolveParentIdAsync(LocationCandidate candidate, StoryIntelligenceLocationImportChoiceForm? choice) + { + var selection = Clean(choice?.ParentSelection); + if (string.Equals(selection, "__none__", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (int.TryParse(selection, out var selectedParentId) && selectedParentId > 0) + { + return selectedParentId; + } + + if (choice?.ParentLocationID is > 0) + { + return choice.ParentLocationID; + } + + if (candidate.ProposedParentLocationID.HasValue) + { + return candidate.ProposedParentLocationID; + } + + if (!string.IsNullOrWhiteSpace(candidate.ProposedParentCandidateKey) + && candidateByKey.TryGetValue(candidate.ProposedParentCandidateKey, out var parentCandidate)) + { + return await MaterialiseLocationCandidateAsync(parentCandidate, choices.GetValueOrDefault(parentCandidate.Key), isDependency: true); + } + + return null; + } + + async Task MaterialiseLocationCandidateAsync(LocationCandidate candidate, StoryIntelligenceLocationImportChoiceForm? choice, bool isDependency) + { + if (resolvedCandidateIds.TryGetValue(candidate.Key, out var resolvedId)) + { + return resolvedId; + } + + if (!materialising.Add(candidate.Key)) + { + return null; + } + + try + { + var action = choice?.Action ?? StoryIntelligenceLocationImportActions.CreateNew; + if (string.Equals(action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase) + || string.Equals(action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var parentId = await ResolveParentIdAsync(candidate, choice); + var requestedName = Clean(choice?.ImportName); + var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.LeafName : requestedName; + var linkExisting = string.Equals(action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); + var matchedExistingId = linkExisting + ? candidate.ExistingLocationID + ?? existingIndex.Find(importName, parentId)?.LocationID + ?? existingIndex.Find(candidate.LeafName, parentId)?.LocationID + : existingIndex.Find(importName, parentId)?.LocationID; + var locationId = matchedExistingId; + + if (!locationId.HasValue) + { + locationId = await locations.SaveAsync(new LocationItem + { + ProjectID = batch.ProjectID, + ParentLocationID = parentId, + LocationName = importName, + LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate), + Description = BuildLocationDescription(candidate), + DetectionPriority = 50 + }); + if (!isDependency) + { + created++; + } + } + else if (!isDependency) + { + linkedExisting++; + } + + AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value); + AddResolvedName(resolvedNames, candidate.LeafName, locationId.Value); + AddResolvedName(resolvedNames, importName, locationId.Value); + resolvedCandidateIds[candidate.Key] = locationId.Value; + if (!isDependency || choice is not null) + { + AddDecision(batch, candidate.Key, action, importName, locationId.Value, true); + } + + return locationId; + } + finally + { + materialising.Remove(candidate.Key); + } + } foreach (var candidate in data.Candidates) { @@ -170,39 +287,7 @@ public sealed class StoryIntelligenceLocationImportService( continue; } - var requestedName = Clean(choice.ImportName); - var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; - var linkExisting = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); - var matchedExistingId = linkExisting - ? candidate.ExistingLocationID - ?? existingIndex.Find(importName)?.LocationID - ?? existingIndex.Find(candidate.DisplayName)?.LocationID - : null; - var locationId = matchedExistingId; - - if (!locationId.HasValue) - { - var parentId = ResolveParentLocationId(candidate.ParentLocationHint, existingIndex); - locationId = await locations.SaveAsync(new LocationItem - { - ProjectID = batch.ProjectID, - ParentLocationID = parentId, - LocationName = importName, - LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate), - Description = BuildLocationDescription(candidate), - DetectionPriority = 50 - }); - created++; - } - else - { - linkedExisting++; - } - - AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value); - AddResolvedName(resolvedNames, importName, locationId.Value); - resolvedCandidateIds[candidate.Key] = locationId.Value; - AddDecision(batch, candidate.Key, choice.Action, importName, locationId.Value, true); + await MaterialiseLocationCandidateAsync(candidate, choice, isDependency: false); } foreach (var candidate in data.Candidates) @@ -351,6 +436,7 @@ public sealed class StoryIntelligenceLocationImportService( } var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + LinkParentCandidates(groups); var candidates = groups.Values .Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked)) .Where(candidate => IsVisibleLocationCandidate(candidate)) @@ -367,31 +453,32 @@ public sealed class StoryIntelligenceLocationImportService( Scene importedScene, SceneIntelligenceScene parsed) { + var sceneParentContext = InferSceneParentContext(importedScene, parsed); var structuralSetting = Clean(importedScene.StructuralSetting); if (!string.IsNullOrWhiteSpace(structuralSetting)) { - AddLocation(groups, existingIndex, importedScene, parsed, structuralSetting, "named", null, null, true, false, true, 0.95m, "Structural setting from Core Import."); + AddLocation(groups, existingIndex, importedScene, parsed, structuralSetting, "named", null, null, null, true, false, true, 0.95m, "Structural setting from Core Import."); } var setting = parsed.Setting; var settingName = ResolveContextualLocationName(FirstConfigured(setting?.LocationName, setting?.GenericRoomType), setting?.GenericRoomType, importedScene); - AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting"); + AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, sceneParentContext, true, false, true, setting?.Confidence, "Scene setting"); foreach (var location in parsed.Locations ?? []) { - AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(location.Name, location.GenericRoomType, importedScene), location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes); + AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(location.Name, location.GenericRoomType, importedScene), location.LocationType, location.GenericRoomType, location.ParentLocationHint, sceneParentContext, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes); } foreach (var observation in parsed.Observations ?? []) { if (IsLocationEntity(observation.SubjectEntityType)) { - AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.SubjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); + AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.SubjectName, null, importedScene), null, null, null, sceneParentContext, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); } if (IsLocationEntity(observation.ObjectEntityType)) { - AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.ObjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); + AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.ObjectName, null, importedScene), null, null, null, sceneParentContext, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); } } } @@ -405,6 +492,7 @@ public sealed class StoryIntelligenceLocationImportService( string? locationType, string? genericRoomType, string? parentLocationHint, + LocationParentContext? sceneParentContext, bool presentInScene, bool mentionedOnly, bool isSceneSetting, @@ -417,20 +505,36 @@ public sealed class StoryIntelligenceLocationImportService( return; } - var identity = ResolveLocationIdentity(cleanName, existingIndex); + var identity = ResolveLocationIdentity(cleanName, null, existingIndex); if (identity is null || !IsLocationNameCandidate(identity.DisplayName)) { return; } - var key = ResolveCandidateKey(groups, identity.Key, identity.DisplayName); + var parentContext = ResolveParentContext(cleanName, identity.DisplayName, genericRoomType, parentLocationHint, sceneParentContext, existingIndex); + EnsureParentCandidate(groups, existingIndex, importedScene, parsed, parentContext); + identity = ResolveLocationIdentity(parentContext.LeafName, parentContext.ProposedParentLocationID, existingIndex); + if (identity is null || !IsLocationNameCandidate(identity.DisplayName)) + { + return; + } + + var key = ParentAwareCandidateKey(parentContext.ParentIdentityKey, identity.Key); if (!groups.TryGetValue(key, out var candidate)) { - var match = identity.ExistingMatch; + var match = parentContext.ParentIdentityKey is not null && !parentContext.ProposedParentLocationID.HasValue + ? null + : identity.ExistingMatch; candidate = new LocationCandidate(key, identity.DisplayName, match?.LocationID, match?.LocationName) { + LeafName = identity.DisplayName, + LeafIdentityKey = identity.Key, Category = Categorise(identity.DisplayName, locationType, genericRoomType), - ParentLocationHint = Clean(parentLocationHint) + ParentIdentityKey = parentContext.ParentIdentityKey, + ParentLocationHint = parentContext.DisplayHint, + ProposedParentLocationID = parentContext.ProposedParentLocationID, + ProposedParentLocationName = parentContext.ProposedParentLocationName, + ProposedParentCandidateKey = parentContext.ProposedParentCandidateKey }; groups[key] = candidate; } @@ -450,7 +554,10 @@ public sealed class StoryIntelligenceLocationImportService( candidate.Confidence = Max(candidate.Confidence, confidence); candidate.Description ??= Clean(notes); candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, identity.DisplayName); - candidate.ParentLocationHint ??= Clean(parentLocationHint); + candidate.ParentLocationHint ??= parentContext.DisplayHint; + candidate.ProposedParentLocationID ??= parentContext.ProposedParentLocationID; + candidate.ProposedParentLocationName ??= parentContext.ProposedParentLocationName; + candidate.ProposedParentCandidateKey ??= parentContext.ProposedParentCandidateKey; var appearance = new LocationAppearanceImport( importedScene.SceneID, importedScene.SceneNumber, @@ -471,16 +578,358 @@ public sealed class StoryIntelligenceLocationImportService( } } + private static void EnsureParentCandidate( + Dictionary groups, + LocationIndex existingIndex, + Scene importedScene, + SceneIntelligenceScene parsed, + LocationParentContext parentContext) + { + if (parentContext.ProposedParentLocationID.HasValue + || string.IsNullOrWhiteSpace(parentContext.ProposedParentLocationName) + || !IsStoryLocation(parentContext.ProposedParentLocationName)) + { + return; + } + + var parentIdentity = ResolveLocationIdentity(parentContext.ProposedParentLocationName, null, existingIndex); + if (parentIdentity is null) + { + return; + } + + var parentKey = ParentAwareCandidateKey(null, parentIdentity.Key); + if (!groups.TryGetValue(parentKey, out var parentCandidate)) + { + parentCandidate = new LocationCandidate(parentKey, parentIdentity.DisplayName, parentIdentity.ExistingMatch?.LocationID, parentIdentity.ExistingMatch?.LocationName) + { + LeafName = parentIdentity.DisplayName, + LeafIdentityKey = parentIdentity.Key, + Category = Categorise(parentIdentity.DisplayName, "named", null) + }; + groups[parentKey] = parentCandidate; + } + + parentCandidate.Confidence = Max(parentCandidate.Confidence, 0.9m); + parentCandidate.Description ??= "Parent location inferred from child location context."; + parentCandidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, parentIdentity.DisplayName); + var appearance = new LocationAppearanceImport( + importedScene.SceneID, + importedScene.SceneNumber, + importedScene.SceneTitle, + BuildSceneReference(importedScene), + parentIdentity.DisplayName, + true, + false, + true, + "Parent inferred from child location context.", + 0.9m, + importedScene.PrimaryLocationID.HasValue && parentCandidate.ExistingLocationID == importedScene.PrimaryLocationID); + if (!parentCandidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID + && string.Equals(existing.ObservedName, appearance.ObservedName, StringComparison.OrdinalIgnoreCase))) + { + parentCandidate.Appearances.Add(appearance); + } + } + + private static void LinkParentCandidates(Dictionary groups) + { + foreach (var candidate in groups.Values) + { + if (candidate.ProposedParentLocationID.HasValue || string.IsNullOrWhiteSpace(candidate.ParentIdentityKey)) + { + continue; + } + + var parentKey = ParentAwareCandidateKey(null, candidate.ParentIdentityKey); + if (groups.ContainsKey(parentKey)) + { + candidate.ProposedParentCandidateKey = parentKey; + } + } + } + + private static LocationParentContext ResolveParentContext( + string observedName, + string identityName, + string? genericRoomType, + string? parentLocationHint, + LocationParentContext? sceneParentContext, + LocationIndex existingIndex) + { + var leafName = ResolveLeafLocationName(observedName, identityName, genericRoomType); + var explicitParent = InferExplicitParent(observedName, leafName); + var parentName = explicitParent + ?? UsefulParentName(parentLocationHint) + ?? (IsParentableGenericLocation(leafName) ? sceneParentContext?.ProposedParentLocationName : null); + var displayHint = FirstConfigured(parentName, parentLocationHint); + if (string.IsNullOrWhiteSpace(parentName)) + { + return new LocationParentContext(leafName, null, null, null, null, Clean(displayHint)); + } + + var parentIdentity = ResolveLocationIdentity(parentName, null, existingIndex); + if (parentIdentity is null) + { + return new LocationParentContext(leafName, null, null, null, null, Clean(displayHint)); + } + + return new LocationParentContext( + leafName, + parentIdentity.Key, + parentIdentity.ExistingMatch?.LocationID, + parentIdentity.ExistingMatch?.LocationName ?? parentIdentity.DisplayName, + null, + Clean(displayHint)); + } + + private static LocationParentContext? InferSceneParentContext(Scene scene, SceneIntelligenceScene parsed) + { + var settingParent = UsefulParentName(parsed.Setting?.ParentLocationHint); + if (!string.IsNullOrWhiteSpace(settingParent)) + { + return new LocationParentContext(settingParent, CanonicalLocationKey(settingParent), null, settingParent, null, settingParent); + } + + var structural = CleanLocationName(scene.StructuralSetting); + var structuralParent = ExtractParentFromCompositeSetting(structural) + ?? ParentFromOwnedLeaf(structural) + ?? (IsUsableParentName(structural) ? structural : null); + if (!string.IsNullOrWhiteSpace(structuralParent)) + { + return new LocationParentContext(structuralParent, CanonicalLocationKey(structuralParent), null, structuralParent, null, structuralParent); + } + + var settingName = CleanLocationName(parsed.Setting?.LocationName); + var settingNameParent = ExtractParentFromCompositeSetting(settingName) + ?? ParentFromOwnedLeaf(settingName) + ?? (IsUsableParentName(settingName) ? settingName : null); + if (!string.IsNullOrWhiteSpace(settingNameParent)) + { + return new LocationParentContext(settingNameParent, CanonicalLocationKey(settingNameParent), null, settingNameParent, null, settingNameParent); + } + + var primary = Clean(scene.PrimaryLocationName ?? scene.PrimaryLocationPath); + return IsUsableParentName(primary) + ? new LocationParentContext(primary, CanonicalLocationKey(primary), null, primary, null, primary) + : null; + } + + private static string ResolveLeafLocationName(string observedName, string identityName, string? genericRoomType) + { + var explicitLeaf = ExplicitLeaf(observedName); + if (!string.IsNullOrWhiteSpace(explicitLeaf)) + { + return explicitLeaf; + } + + var genericLeaf = CleanLocationName(genericRoomType); + if (IsParentableGenericLocation(genericLeaf)) + { + return genericLeaf; + } + + if (IsParentableGenericLocation(identityName)) + { + return identityName; + } + + return identityName; + } + + private static string? ExplicitLeaf(string observedName) + { + var clean = CleanLocationName(observedName); + var possessive = PossessiveGenericLocation.Match(clean); + if (possessive.Success) + { + return CleanLocationName(possessive.Groups["leaf"].Value); + } + + var genericAt = GenericAtOwnedLocation.Match(clean); + return genericAt.Success ? CleanLocationName(genericAt.Groups["leaf"].Value) : null; + } + + private static string? InferExplicitParent(string observedName, string leafName) + { + var clean = CleanLocationName(observedName); + var possessive = PossessiveGenericLocation.Match(clean); + if (possessive.Success) + { + var owner = CleanLocationName(possessive.Groups["owner"].Value); + return IsCleanOwnerName(owner) ? ParentNameFromOwner(owner) : null; + } + + var genericAt = GenericAtOwnedLocation.Match(clean); + if (genericAt.Success) + { + var owner = CleanLocationName(genericAt.Groups["owner"].Value); + return IsCleanOwnerName(owner) ? ParentNameFromOwner(owner) : null; + } + + return null; + } + + private static string? ParentFromOwnedLeaf(string value) + { + var clean = CleanLocationName(value); + var matches = PossessiveGenericLocation.Matches(StripLeadingContextPhrases(clean)) + .Cast() + .ToList(); + if (matches.Count == 0) + { + matches = Regex.Matches(clean, @"(?[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\b", RegexOptions.IgnoreCase) + .Cast() + .ToList(); + } + + foreach (var match in matches.AsEnumerable().Reverse()) + { + var owner = CleanLocationName(match.Groups["owner"].Value); + if (!IsCleanOwnerName(owner)) + { + continue; + } + + return ParentNameFromOwner(owner); + } + + return null; + } + + private static string? ParentNameFromOwner(string owner) + { + var clean = CleanLocationName(owner); + if (string.IsNullOrWhiteSpace(clean)) + { + return null; + } + + var normalised = Normalise(clean); + if (normalised.EndsWith(" house", StringComparison.OrdinalIgnoreCase) + || normalised.EndsWith(" home", StringComparison.OrdinalIgnoreCase) + || normalised.EndsWith(" flat", StringComparison.OrdinalIgnoreCase) + || normalised.EndsWith(" apartment", StringComparison.OrdinalIgnoreCase)) + { + return clean; + } + + return $"{clean}'s home"; + } + + private static string? ExtractParentFromCompositeSetting(string? value) + { + var clean = CleanLocationName(value); + if (string.IsNullOrWhiteSpace(clean)) + { + return null; + } + + var ownedContainer = Regex.Match(clean, @"(?[\p{L}\d][\p{L}\d\s.'’-]{1,80}['’]s\s+(?:house|home|flat|apartment))\s+(?:and|with)\s+(?:bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing)\b", RegexOptions.IgnoreCase); + if (ownedContainer.Success) + { + var parent = CleanLocationName(ownedContainer.Groups["parent"].Value); + if (IsUsableParentName(parent)) + { + return parent; + } + } + + var parts = Regex.Split(clean, @"\s*(?:--|-|—|;|,|\bthen\b|\bto\b)\s*", RegexOptions.IgnoreCase) + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(CleanLocationName) + .ToList(); + foreach (var part in parts) + { + var ownedParent = ParentFromOwnedLeaf(part); + if (IsUsableParentName(ownedParent)) + { + return ownedParent; + } + } + + foreach (var part in parts) + { + if (IsUsableParentName(part)) + { + return part; + } + } + + return null; + } + + private static string? UsefulParentName(string? parentHint) + { + var clean = CleanLocationName(parentHint); + if (Regex.IsMatch(clean, @"(?:--|-|—|;|,|\bthen\b|\bto\b)", RegexOptions.IgnoreCase)) + { + return ExtractParentFromCompositeSetting(clean); + } + + return IsUsableParentName(clean) ? clean : null; + } + + private static bool IsUsableParentName(string? value) + { + var clean = CleanLocationName(value); + return !string.IsNullOrWhiteSpace(clean) + && IsStoryLocation(clean) + && !IsParentableGenericLocation(clean) + && !ContainsParentableGenericLocationWord(clean) + && !IsGenericComposite(clean) + && !Regex.IsMatch(clean, @"\b(?:and|or|to|from|between|through)\b", RegexOptions.IgnoreCase) + && !Regex.IsMatch(clean, @"\b(?:road|lane|street|avenue|drive|way|close|court|terrace|square)\b$", RegexOptions.IgnoreCase) + && !ContainsThrowawayFragment(clean); + } + + private static bool IsCleanOwnerName(string? value) + { + var clean = CleanLocationName(value); + return !string.IsNullOrWhiteSpace(clean) + && !Regex.IsMatch(clean, @"\b(?:and|or|to|from|between|through|beneath|outside|inside)\b", RegexOptions.IgnoreCase); + } + + private static bool ContainsParentableGenericLocationWord(string value) + { + var normalised = Normalise(value); + return ParentableGenericLocations.Any(location => normalised.Equals(location, StringComparison.OrdinalIgnoreCase) + || normalised.Contains($" {location}", StringComparison.OrdinalIgnoreCase) + || normalised.Contains($"{location} ", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsParentableGenericLocation(string? name) + { + var clean = Normalise(name); + return ParentableGenericLocations.Contains(clean) + || ParentableGenericLocations.Contains(Normalise(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty))); + } + + private static string ParentAwareCandidateKey(string? parentIdentityKey, string leafIdentityKey) + => $"{(string.IsNullOrWhiteSpace(parentIdentityKey) ? "unknown" : parentIdentityKey)}|{leafIdentityKey}"; + + private static IReadOnlyList ToParentOptions(IEnumerable locations) + => locations + .OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase) + .ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase) + .Select(location => new StoryIntelligenceLocationParentOptionViewModel + { + LocationID = location.LocationID, + LocationName = location.LocationName, + LocationPath = location.LocationPath + }) + .ToList(); + private async Task BuildLocationIndexAsync(int projectId) { var index = new LocationIndex(); foreach (var location in await locations.ListByProjectAsync(projectId)) { - index.Add(location.LocationName, location.LocationID, location.LocationName); - index.Add(location.LocationPath, location.LocationID, location.LocationName); + index.Add(location.LocationName, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath); + index.Add(location.LocationPath, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath); foreach (var alias in await locations.ListAliasesAsync(location.LocationID)) { - index.Add(alias.Alias, location.LocationID, location.LocationName); + index.Add(alias.Alias, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath); } } @@ -866,7 +1315,7 @@ public sealed class StoryIntelligenceLocationImportService( return StoryIntelligenceEntityTextNormaliser.SingulariseSafeFinalWord(clean, SingularisableLocationWords); } - private static LocationIdentity? ResolveLocationIdentity(string name, LocationIndex existingIndex) + private static LocationIdentity? ResolveLocationIdentity(string name, int? parentLocationId, LocationIndex existingIndex) { var clean = CleanLocationName(name); if (string.IsNullOrWhiteSpace(clean)) @@ -874,14 +1323,14 @@ public sealed class StoryIntelligenceLocationImportService( return null; } - var existingMatch = existingIndex.Find(clean); + var existingMatch = existingIndex.Find(clean, parentLocationId); if (existingMatch is not null) { return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch); } var identityName = NormaliseLocationIdentityName(clean); - existingMatch = existingIndex.Find(identityName); + existingMatch = existingIndex.Find(identityName, parentLocationId); if (existingMatch is not null) { return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch); @@ -946,27 +1395,6 @@ public sealed class StoryIntelligenceLocationImportService( return Regex.Replace(clean, @"\s+and\s*$", string.Empty, RegexOptions.IgnoreCase).Trim(); } - private static string ResolveCandidateKey( - IReadOnlyDictionary groups, - string canonicalKey, - string displayName) - { - if (groups.ContainsKey(canonicalKey)) - { - return canonicalKey; - } - - if (!IsGenericLocation(displayName)) - { - return canonicalKey; - } - - var contextual = groups.Keys - .Where(key => key.EndsWith($" {canonicalKey}", StringComparison.OrdinalIgnoreCase)) - .ToList(); - return contextual.Count == 1 ? contextual[0] : canonicalKey; - } - private static string StripLowValueContextSuffix(string value) { var clean = Clean(value); @@ -1132,30 +1560,54 @@ public sealed class StoryIntelligenceLocationImportService( private sealed class LocationIndex { private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary byParentAndName = new(StringComparer.OrdinalIgnoreCase); - public void Add(string? name, int locationId, string locationName) + public void Add(string? name, int locationId, string locationName, int? parentLocationId, string? parentLocationName, string? locationPath) { var key = CanonicalLocationKey(name ?? string.Empty); if (!string.IsNullOrWhiteSpace(key)) { - byName.TryAdd(key, new LocationMatch(locationId, locationName)); + var match = new LocationMatch(locationId, locationName, parentLocationId, parentLocationName, locationPath); + byName.TryAdd(key, match); + byParentAndName.TryAdd(ParentAwareCandidateKey(parentLocationId?.ToString(), key), match); } } - public LocationMatch? Find(string name) - => byName.TryGetValue(CanonicalLocationKey(name), out var match) ? match : null; + public LocationMatch? Find(string name, int? parentLocationId = null) + { + var key = CanonicalLocationKey(name); + if (string.IsNullOrWhiteSpace(key)) + { + return null; + } + + if (parentLocationId.HasValue) + { + return byParentAndName.TryGetValue(ParentAwareCandidateKey(parentLocationId.Value.ToString(), key), out var parentMatch) + ? parentMatch + : null; + } + + return byName.TryGetValue(key, out var match) ? match : null; + } } - private sealed record LocationMatch(int LocationID, string LocationName); + private sealed record LocationMatch(int LocationID, string LocationName, int? ParentLocationID, string? ParentLocationName, string? LocationPath); private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName) { public string Key { get; } = key; public string DisplayName { get; set; } = displayName; + public string LeafName { get; set; } = displayName; + public string LeafIdentityKey { get; set; } = key; public int? ExistingLocationID { get; } = existingLocationId; public string? ExistingLocationName { get; } = existingLocationName; public string Category { get; set; } = "Ambiguous location"; + public string? ParentIdentityKey { get; set; } public string? ParentLocationHint { get; set; } + public int? ProposedParentLocationID { get; set; } + public string? ProposedParentLocationName { get; set; } + public string? ProposedParentCandidateKey { get; set; } public HashSet Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); public List Appearances { get; } = []; public decimal? Confidence { get; set; } @@ -1178,6 +1630,14 @@ public sealed class StoryIntelligenceLocationImportService( private sealed record LocationIdentity(string Key, string DisplayName, LocationMatch? ExistingMatch); + private sealed record LocationParentContext( + string LeafName, + string? ParentIdentityKey, + int? ProposedParentLocationID, + string? ProposedParentLocationName, + string? ProposedParentCandidateKey, + string? DisplayHint); + private sealed record LocationCandidateData( bool HasCommittedScenes, int AlreadyLinkedCount, diff --git a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs index da3fd0b..8dc5859 100644 --- a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs +++ b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs @@ -31,6 +31,7 @@ public sealed class StoryIntelligenceReviewCandidateService( IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceKnowledgeImportService knowledgeImport, ICharacterRepository characters, + ILocationRepository locations, IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceReviewCandidateService { @@ -147,6 +148,16 @@ public sealed class StoryIntelligenceReviewCandidateService( public async Task GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize); + var parentOptions = (await locations.ListByProjectAsync(batch.ProjectID)) + .OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase) + .ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase) + .Select(location => new StoryIntelligenceLocationParentOptionViewModel + { + LocationID = location.LocationID, + LocationName = location.LocationName, + LocationPath = location.LocationPath + }) + .ToList(); return new StoryIntelligenceLocationReviewViewModel { HasCommittedScenes = true, @@ -157,6 +168,7 @@ public sealed class StoryIntelligenceReviewCandidateService( PageSize = page.PageSize, TotalCandidateCount = page.TotalCount, PendingCandidateCount = page.PendingCount, + ParentLocationOptions = parentOptions, Candidates = page.Items }; } diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index bbcbd87..fab83fe 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -453,18 +453,32 @@ public sealed class StoryIntelligenceLocationReviewViewModel public int PendingCandidateCount { get; init; } public bool HasPreviousPage => PageNumber > 1; public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount; + public IReadOnlyList ParentLocationOptions { get; init; } = []; public IReadOnlyList Candidates { get; init; } = []; } +public sealed class StoryIntelligenceLocationParentOptionViewModel +{ + public int LocationID { get; init; } + public string LocationName { get; init; } = string.Empty; + public string LocationPath { get; init; } = string.Empty; + public string DisplayName => string.IsNullOrWhiteSpace(LocationPath) ? LocationName : LocationPath; +} + public sealed class StoryIntelligenceLocationReviewCandidateViewModel { public string Key { get; init; } = string.Empty; public string LocationName { get; init; } = string.Empty; + public string LeafLocationName { get; init; } = string.Empty; public string ImportName { get; init; } = string.Empty; public string Category { get; init; } = "Ambiguous location"; public int AppearsInScenes { get; init; } public string Confidence { get; init; } = "Unknown"; public string? ParentLocationHint { get; init; } + public string? ParentIdentityKey { get; init; } + public int? ProposedParentLocationID { get; init; } + public string? ProposedParentLocationName { get; init; } + public string? ProposedParentCandidateKey { get; init; } public string? ExampleFirstAppearance { get; init; } public string? ExampleContext { get; init; } public IReadOnlyList EvidenceSummaries { get; init; } = []; @@ -486,6 +500,8 @@ public sealed class StoryIntelligenceLocationImportChoiceForm public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew; public string? ImportName { get; set; } public string? AliasTargetKey { get; set; } + public int? ParentLocationID { get; set; } + public string? ParentSelection { get; set; } } public static class StoryIntelligenceLocationImportActions diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml index 80ba592..04a9fa2 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml @@ -53,7 +53,8 @@ { var candidate = locationCandidates[i]; var status = candidate.IsExistingMatch ? "existing" : "new"; - var searchText = string.Join(" ", new[] { candidate.LocationName, candidate.ExistingLocationName, candidate.Category, candidate.ParentLocationHint }); + var proposedParentLabel = candidate.ProposedParentLocationName ?? candidate.ParentLocationHint; + var searchText = string.Join(" ", new[] { candidate.LocationName, candidate.ExistingLocationName, candidate.Category, candidate.ParentLocationHint, candidate.ProposedParentLocationName });
@@ -68,7 +69,7 @@
First appearance
@Display(candidate.ExampleFirstAppearance)
-
Parent hint
@Display(candidate.ParentLocationHint)
+
Proposed parent
@Display(proposedParentLabel)
Existing match
@(candidate.ExistingLocationName ?? "None")
@@ -126,6 +127,35 @@
+
+ + +
+