Make location candidates parent-aware
This commit is contained in:
parent
0c3f215574
commit
3c016c904e
@ -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(
|
||||
|
||||
@ -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<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(
|
||||
"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(@"^(?<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 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<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var resolvedCandidateIds = new Dictionary<string, int>(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)
|
||||
{
|
||||
@ -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<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)
|
||||
{
|
||||
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<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)
|
||||
{
|
||||
var clean = Clean(value);
|
||||
@ -1132,30 +1560,54 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
private sealed class LocationIndex
|
||||
{
|
||||
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);
|
||||
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<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<LocationAppearanceImport> 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,
|
||||
|
||||
@ -31,6 +31,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
||||
ICharacterRepository characters,
|
||||
ILocationRepository locations,
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService
|
||||
{
|
||||
@ -147,6 +148,16 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int 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
|
||||
{
|
||||
HasCommittedScenes = true,
|
||||
@ -157,6 +168,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
PageSize = page.PageSize,
|
||||
TotalCandidateCount = page.TotalCount,
|
||||
PendingCandidateCount = page.PendingCount,
|
||||
ParentLocationOptions = parentOptions,
|
||||
Candidates = page.Items
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { 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 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<string> 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
|
||||
|
||||
@ -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 });
|
||||
<details class="story-character-card" open data-location-card data-location-status="@status" data-location-search-text="@searchText">
|
||||
<summary>
|
||||
<span>
|
||||
@ -68,7 +69,7 @@
|
||||
<div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName">
|
||||
<dl>
|
||||
<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>
|
||||
</dl>
|
||||
|
||||
@ -126,6 +127,35 @@
|
||||
<input id="location-import-name-@i" class="form-control" name="Locations[@i].ImportName" value="@candidate.ImportName" />
|
||||
</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>
|
||||
<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>
|
||||
@ -208,6 +238,8 @@
|
||||
const namePanel = card.querySelector("[data-location-import-name-panel]");
|
||||
const aliasPanel = card.querySelector("[data-location-alias-panel]");
|
||||
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";
|
||||
updateAliasTargets();
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user