Make location candidates parent-aware

This commit is contained in:
Nick Beckley 2026-08-31 12:54:15 +00:00
parent 0c3f215574
commit 3c016c904e
5 changed files with 692 additions and 78 deletions

View File

@ -48,6 +48,7 @@ var tests = new (string Name, Action Test)[]
("Location contextual names use POV and structural setting", LocationContextualNamesUsePovAndStructuralSetting), ("Location contextual names use POV and structural setting", LocationContextualNamesUsePovAndStructuralSetting),
("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals), ("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals),
("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants), ("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), ("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), ("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."); 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() static void LocationReviewUxSupportsLargeCandidateSets()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); 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])!; 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) static string LocationCanonicalKey(string name)
{ {
var method = typeof(StoryIntelligenceLocationImportService).GetMethod( var method = typeof(StoryIntelligenceLocationImportService).GetMethod(

View File

@ -42,6 +42,11 @@ public sealed class StoryIntelligenceLocationImportService(
"landing", "doorway", "pew", "altar", "floor", "entrance", "office", "bedroom", "garage", "landing", "doorway", "pew", "altar", "floor", "entrance", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "garden", "staff room", "lift"); "seating area", "waiting room", "interview room", "garden", "staff room", "lift");
private static readonly HashSet<string> 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<string> RepeatableGenericSettings = CreateSet( private static readonly HashSet<string> RepeatableGenericSettings = CreateSet(
"kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "staff room", "car park", "parking lot", "tunnel"); "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 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 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 PossessiveControlCharacter = new(@"(?<=\p{L})[\u0000-\u001F\u007F]+(?=s\b)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex PossessiveGenericLocation = new(@"^(?<owner>[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?<leaf>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+)?(?<leaf>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+(?<owner>[\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 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 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); private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
@ -84,21 +91,28 @@ public sealed class StoryIntelligenceLocationImportService(
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
var parentOptions = ToParentOptions(await locations.ListByProjectAsync(batch.ProjectID));
return new StoryIntelligenceLocationReviewViewModel return new StoryIntelligenceLocationReviewViewModel
{ {
HasCommittedScenes = data.HasCommittedScenes, HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0, CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount, AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
ParentLocationOptions = parentOptions,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
{ {
Key = candidate.Key, Key = candidate.Key,
LocationName = candidate.DisplayName, LocationName = candidate.DisplayName,
ImportName = candidate.DisplayName, LeafLocationName = candidate.LeafName,
ImportName = candidate.LeafName,
Category = candidate.Category, Category = candidate.Category,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence), Confidence = ConfidenceBand(candidate.Confidence),
ParentLocationHint = candidate.ParentLocationHint, ParentLocationHint = candidate.ParentLocationHint,
ParentIdentityKey = candidate.ParentIdentityKey,
ProposedParentLocationID = candidate.ProposedParentLocationID,
ProposedParentLocationName = candidate.ProposedParentLocationName,
ProposedParentCandidateKey = candidate.ProposedParentCandidateKey,
ExampleFirstAppearance = candidate.FirstAppearanceNote, ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExampleContext = candidate.Description, ExampleContext = candidate.Description,
ExistingLocationID = candidate.ExistingLocationID, ExistingLocationID = candidate.ExistingLocationID,
@ -150,6 +164,109 @@ public sealed class StoryIntelligenceLocationImportService(
var resolvedNames = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var resolvedNames = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase); var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
var materialising = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
async Task<int?> 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<int?> 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) foreach (var candidate in data.Candidates)
{ {
@ -170,39 +287,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue; continue;
} }
var requestedName = Clean(choice.ImportName); await MaterialiseLocationCandidateAsync(candidate, choice, isDependency: false);
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);
} }
foreach (var candidate in data.Candidates) 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); var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
LinkParentCandidates(groups);
var candidates = groups.Values var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked)) .Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(candidate => IsVisibleLocationCandidate(candidate)) .Where(candidate => IsVisibleLocationCandidate(candidate))
@ -367,31 +453,32 @@ public sealed class StoryIntelligenceLocationImportService(
Scene importedScene, Scene importedScene,
SceneIntelligenceScene parsed) SceneIntelligenceScene parsed)
{ {
var sceneParentContext = InferSceneParentContext(importedScene, parsed);
var structuralSetting = Clean(importedScene.StructuralSetting); var structuralSetting = Clean(importedScene.StructuralSetting);
if (!string.IsNullOrWhiteSpace(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 setting = parsed.Setting;
var settingName = ResolveContextualLocationName(FirstConfigured(setting?.LocationName, setting?.GenericRoomType), setting?.GenericRoomType, importedScene); 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 ?? []) 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 ?? []) foreach (var observation in parsed.Observations ?? [])
{ {
if (IsLocationEntity(observation.SubjectEntityType)) 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)) 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? locationType,
string? genericRoomType, string? genericRoomType,
string? parentLocationHint, string? parentLocationHint,
LocationParentContext? sceneParentContext,
bool presentInScene, bool presentInScene,
bool mentionedOnly, bool mentionedOnly,
bool isSceneSetting, bool isSceneSetting,
@ -417,20 +505,36 @@ public sealed class StoryIntelligenceLocationImportService(
return; return;
} }
var identity = ResolveLocationIdentity(cleanName, existingIndex); var identity = ResolveLocationIdentity(cleanName, null, existingIndex);
if (identity is null || !IsLocationNameCandidate(identity.DisplayName)) if (identity is null || !IsLocationNameCandidate(identity.DisplayName))
{ {
return; 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)) 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) candidate = new LocationCandidate(key, identity.DisplayName, match?.LocationID, match?.LocationName)
{ {
LeafName = identity.DisplayName,
LeafIdentityKey = identity.Key,
Category = Categorise(identity.DisplayName, locationType, genericRoomType), 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; groups[key] = candidate;
} }
@ -450,7 +554,10 @@ public sealed class StoryIntelligenceLocationImportService(
candidate.Confidence = Max(candidate.Confidence, confidence); candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.Description ??= Clean(notes); candidate.Description ??= Clean(notes);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, identity.DisplayName); 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( var appearance = new LocationAppearanceImport(
importedScene.SceneID, importedScene.SceneID,
importedScene.SceneNumber, importedScene.SceneNumber,
@ -471,16 +578,358 @@ public sealed class StoryIntelligenceLocationImportService(
} }
} }
private static void EnsureParentCandidate(
Dictionary<string, LocationCandidate> 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<string, LocationCandidate> 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<Match>()
.ToList();
if (matches.Count == 0)
{
matches = Regex.Matches(clean, @"(?<owner>[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?<leaf>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<Match>()
.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, @"(?<parent>[\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<StoryIntelligenceLocationParentOptionViewModel> ToParentOptions(IEnumerable<LocationItem> 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<LocationIndex> BuildLocationIndexAsync(int projectId) private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
{ {
var index = new LocationIndex(); var index = new LocationIndex();
foreach (var location in await locations.ListByProjectAsync(projectId)) foreach (var location in await locations.ListByProjectAsync(projectId))
{ {
index.Add(location.LocationName, 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); index.Add(location.LocationPath, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath);
foreach (var alias in await locations.ListAliasesAsync(location.LocationID)) 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); 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); var clean = CleanLocationName(name);
if (string.IsNullOrWhiteSpace(clean)) if (string.IsNullOrWhiteSpace(clean))
@ -874,14 +1323,14 @@ public sealed class StoryIntelligenceLocationImportService(
return null; return null;
} }
var existingMatch = existingIndex.Find(clean); var existingMatch = existingIndex.Find(clean, parentLocationId);
if (existingMatch is not null) if (existingMatch is not null)
{ {
return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch); return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch);
} }
var identityName = NormaliseLocationIdentityName(clean); var identityName = NormaliseLocationIdentityName(clean);
existingMatch = existingIndex.Find(identityName); existingMatch = existingIndex.Find(identityName, parentLocationId);
if (existingMatch is not null) if (existingMatch is not null)
{ {
return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch); 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(); return Regex.Replace(clean, @"\s+and\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
} }
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, LocationCandidate> 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) private static string StripLowValueContextSuffix(string value)
{ {
var clean = Clean(value); var clean = Clean(value);
@ -1132,30 +1560,54 @@ public sealed class StoryIntelligenceLocationImportService(
private sealed class LocationIndex private sealed class LocationIndex
{ {
private readonly Dictionary<string, LocationMatch> byName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, LocationMatch> byName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, LocationMatch> 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); var key = CanonicalLocationKey(name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(key)) 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) public LocationMatch? Find(string name, int? parentLocationId = null)
=> byName.TryGetValue(CanonicalLocationKey(name), out var match) ? match : 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) private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName)
{ {
public string Key { get; } = key; public string Key { get; } = key;
public string DisplayName { get; set; } = displayName; public string DisplayName { get; set; } = displayName;
public string LeafName { get; set; } = displayName;
public string LeafIdentityKey { get; set; } = key;
public int? ExistingLocationID { get; } = existingLocationId; public int? ExistingLocationID { get; } = existingLocationId;
public string? ExistingLocationName { get; } = existingLocationName; public string? ExistingLocationName { get; } = existingLocationName;
public string Category { get; set; } = "Ambiguous location"; public string Category { get; set; } = "Ambiguous location";
public string? ParentIdentityKey { get; set; }
public string? ParentLocationHint { get; set; } public string? ParentLocationHint { get; set; }
public int? ProposedParentLocationID { get; set; }
public string? ProposedParentLocationName { get; set; }
public string? ProposedParentCandidateKey { get; set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<LocationAppearanceImport> Appearances { get; } = []; public List<LocationAppearanceImport> Appearances { get; } = [];
public decimal? Confidence { get; set; } 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 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( private sealed record LocationCandidateData(
bool HasCommittedScenes, bool HasCommittedScenes,
int AlreadyLinkedCount, int AlreadyLinkedCount,

View File

@ -31,6 +31,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport, IStoryIntelligenceKnowledgeImportService knowledgeImport,
ICharacterRepository characters, ICharacterRepository characters,
ILocationRepository locations,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService
{ {
@ -147,6 +148,16 @@ public sealed class StoryIntelligenceReviewCandidateService(
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{ {
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize); var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(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 return new StoryIntelligenceLocationReviewViewModel
{ {
HasCommittedScenes = true, HasCommittedScenes = true,
@ -157,6 +168,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
PageSize = page.PageSize, PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount, TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount, PendingCandidateCount = page.PendingCount,
ParentLocationOptions = parentOptions,
Candidates = page.Items Candidates = page.Items
}; };
} }

View File

@ -453,18 +453,32 @@ public sealed class StoryIntelligenceLocationReviewViewModel
public int PendingCandidateCount { get; init; } public int PendingCandidateCount { get; init; }
public bool HasPreviousPage => PageNumber > 1; public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount; public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> 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 sealed class StoryIntelligenceLocationReviewCandidateViewModel
{ {
public string Key { get; init; } = string.Empty; public string Key { get; init; } = string.Empty;
public string LocationName { 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 ImportName { get; init; } = string.Empty;
public string Category { get; init; } = "Ambiguous location"; public string Category { get; init; } = "Ambiguous location";
public int AppearsInScenes { get; init; } public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown"; public string Confidence { get; init; } = "Unknown";
public string? ParentLocationHint { get; init; } 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? ExampleFirstAppearance { get; init; }
public string? ExampleContext { get; init; } public string? ExampleContext { get; init; }
public IReadOnlyList<string> EvidenceSummaries { get; init; } = []; public IReadOnlyList<string> EvidenceSummaries { get; init; } = [];
@ -486,6 +500,8 @@ public sealed class StoryIntelligenceLocationImportChoiceForm
public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew; public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew;
public string? ImportName { get; set; } public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; } public string? AliasTargetKey { get; set; }
public int? ParentLocationID { get; set; }
public string? ParentSelection { get; set; }
} }
public static class StoryIntelligenceLocationImportActions public static class StoryIntelligenceLocationImportActions

View File

@ -53,7 +53,8 @@
{ {
var candidate = locationCandidates[i]; var candidate = locationCandidates[i];
var status = candidate.IsExistingMatch ? "existing" : "new"; 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 });
<details class="story-character-card" open data-location-card data-location-status="@status" data-location-search-text="@searchText"> <details class="story-character-card" open data-location-card data-location-status="@status" data-location-search-text="@searchText">
<summary> <summary>
<span> <span>
@ -68,7 +69,7 @@
<div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName"> <div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName">
<dl> <dl>
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div> <div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
<div><dt>Parent hint</dt><dd>@Display(candidate.ParentLocationHint)</dd></div> <div><dt>Proposed parent</dt><dd>@Display(proposedParentLabel)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingLocationName ?? "None")</dd></div> <div><dt>Existing match</dt><dd>@(candidate.ExistingLocationName ?? "None")</dd></div>
</dl> </dl>
@ -126,6 +127,35 @@
<input id="location-import-name-@i" class="form-control" name="Locations[@i].ImportName" value="@candidate.ImportName" /> <input id="location-import-name-@i" class="form-control" name="Locations[@i].ImportName" value="@candidate.ImportName" />
</div> </div>
<div data-location-parent-panel>
<label class="form-label" for="location-parent-@i">Parent location</label>
<select id="location-parent-@i" class="form-select" name="Locations[@i].ParentSelection">
@if (!string.IsNullOrWhiteSpace(proposedParentLabel))
{
<option value="__proposed__" selected>Use proposed: @proposedParentLabel</option>
}
@if (string.IsNullOrWhiteSpace(proposedParentLabel))
{
<option value="__none__" selected>Not determined</option>
}
else
{
<option value="__none__">Not determined</option>
}
@foreach (var parent in Model.LocationReview.ParentLocationOptions)
{
@if (candidate.ProposedParentLocationID == parent.LocationID)
{
<option value="@parent.LocationID" selected>@parent.DisplayName</option>
}
else
{
<option value="@parent.LocationID">@parent.DisplayName</option>
}
}
</select>
</div>
<div data-location-alias-panel hidden> <div data-location-alias-panel hidden>
<label class="form-label" for="location-alias-target-@i">Alias/variant target</label> <label class="form-label" for="location-alias-target-@i">Alias/variant target</label>
<select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target> <select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target>
@ -208,6 +238,8 @@
const namePanel = card.querySelector("[data-location-import-name-panel]"); const namePanel = card.querySelector("[data-location-import-name-panel]");
const aliasPanel = card.querySelector("[data-location-alias-panel]"); const aliasPanel = card.querySelector("[data-location-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias"; if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
const parentPanel = card.querySelector("[data-location-parent-panel]");
if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias"; if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
updateAliasTargets(); updateAliasTargets();
}; };