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 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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user