Consolidate Story Intelligence location candidates
This commit is contained in:
parent
6dec8a4345
commit
b7f24954e1
@ -37,6 +37,8 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
|
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
|
||||||
("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 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),
|
||||||
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
||||||
@ -897,6 +899,14 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering()
|
|||||||
foreach (var viewName in heavyViews)
|
foreach (var viewName in heavyViews)
|
||||||
{
|
{
|
||||||
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
|
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
|
||||||
|
if (viewName == "StoryIntelligenceLocations.cshtml")
|
||||||
|
{
|
||||||
|
Assert(view.Contains("const pageSize = 24;", StringComparison.Ordinal), "StoryIntelligenceLocations.cshtml should cap visible candidates with DOM-preserving pagination.");
|
||||||
|
Assert(view.Contains("data-location-page-prev", StringComparison.Ordinal), "StoryIntelligenceLocations.cshtml should provide previous-page navigation.");
|
||||||
|
Assert(view.Contains("data-location-page-next", StringComparison.Ordinal), "StoryIntelligenceLocations.cshtml should provide next-page navigation.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
Assert(view.Contains("const int previewLimit = 50", StringComparison.Ordinal), $"{viewName} should cap default rendering.");
|
Assert(view.Contains("const int previewLimit = 50", StringComparison.Ordinal), $"{viewName} should cap default rendering.");
|
||||||
Assert(view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should provide a View All route.");
|
Assert(view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should provide a View All route.");
|
||||||
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions.");
|
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions.");
|
||||||
@ -1927,7 +1937,6 @@ static void LocationFilteringRejectsMergedPhrases()
|
|||||||
|
|
||||||
static void LocationFilteringPreservesStoryLocations()
|
static void LocationFilteringPreservesStoryLocations()
|
||||||
{
|
{
|
||||||
Assert(IsStoryLocation("The Doweries"), "The Doweries should be preserved.");
|
|
||||||
Assert(IsStoryLocation("Ashdown Trust"), "Ashdown Trust should be preserved.");
|
Assert(IsStoryLocation("Ashdown Trust"), "Ashdown Trust should be preserved.");
|
||||||
Assert(IsStoryLocation("St Luke's Church"), "St Luke's Church should be preserved.");
|
Assert(IsStoryLocation("St Luke's Church"), "St Luke's Church should be preserved.");
|
||||||
Assert(IsStoryLocation("Mrs Patterson's House"), "Mrs Patterson's House should be preserved.");
|
Assert(IsStoryLocation("Mrs Patterson's House"), "Mrs Patterson's House should be preserved.");
|
||||||
@ -1960,12 +1969,66 @@ static void LocationCanonicalKeysMergePunctuationAndSafePlurals()
|
|||||||
Assert(!IsStoryLocation("road"), "Incidental generic road should not be a strong story location.");
|
Assert(!IsStoryLocation("road"), "Incidental generic road should not be a strong story location.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void LocationCandidateConsolidationHandlesGenericReviewVariants()
|
||||||
|
{
|
||||||
|
Assert(LocationCanonicalKey("The Crown Inn") == LocationCanonicalKey("the Crown Inn"), "Case-only variants should merge.");
|
||||||
|
Assert(LocationCanonicalKey("The Crown Inn") == LocationCanonicalKey("Crown Inn"), "Leading article variants should merge.");
|
||||||
|
Assert(LocationCanonicalKey("inside The Crown Inn") == LocationCanonicalKey("at the Crown Inn"), "Context wrappers should not create separate candidates.");
|
||||||
|
Assert(LocationCanonicalKey("The Crown Inn interior, early evening") == LocationCanonicalKey("Crown Inn"), "Temporal and broad interior suffixes should fold into the named place.");
|
||||||
|
Assert(LocationCanonicalKey("Mrs Patterson's house") == LocationCanonicalKey("Mrs. Patterson's house"), "Mrs/Mrs. punctuation should not split a house candidate.");
|
||||||
|
Assert(LocationCanonicalKey("Dr Hale's house") == LocationCanonicalKey("Dr. Hale's house"), "Unrelated title punctuation fixture should also merge.");
|
||||||
|
|
||||||
|
Assert(LocationCanonicalKey("Nora's house") != LocationCanonicalKey("Imani's house"), "Distinct owned houses must not merge.");
|
||||||
|
Assert(LocationCanonicalKey("The Crown Inn") != LocationCanonicalKey("The Crown Inn cellar"), "Meaningful sub-locations should remain separate.");
|
||||||
|
Assert(LocationCanonicalKey("The Meridian Hotel") != LocationCanonicalKey("The Meridian Hotel kitchen"), "Unrelated named-place sub-location fixture should remain separate.");
|
||||||
|
|
||||||
|
Assert(IsGenericLocation("the tunnel"), "Tunnel should be recognised as a generic setting label.");
|
||||||
|
Assert(RepeatableGenericSettingKeys().Contains(LocationCanonicalKey("the tunnel")), "Repeated active tunnel settings should be allowed to survive review.");
|
||||||
|
Assert(!IsStoryLocation("a stretch of road"), "One-off incidental road prose should not be treated as a strong named location.");
|
||||||
|
Assert(IsGenericComposite("Nora's house and Imani's house"), "Multi-location owned-place conjunctions should not become one fake location.");
|
||||||
|
Assert(!IsMergedLocationPhrase("Rose and Crown"), "Generic named compounds should not need a production special-case exception.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LocationReviewUxSupportsLargeCandidateSets()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
|
||||||
|
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||||
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
|
||||||
|
|
||||||
|
Assert(view.Contains("data-location-search", StringComparison.Ordinal), "Location review should provide candidate search.");
|
||||||
|
Assert(view.Contains("data-location-status-filter", StringComparison.Ordinal), "Location review should provide status filtering.");
|
||||||
|
Assert(view.Contains("const pageSize = 24;", StringComparison.Ordinal), "Location review should show roughly 20-25 candidates at once.");
|
||||||
|
Assert(view.Contains("data-location-page-status", StringComparison.Ordinal), "Location review should show paging/review counts.");
|
||||||
|
Assert(view.Contains("EvidenceSummaries", StringComparison.Ordinal), "Location review should expose compact aggregated evidence.");
|
||||||
|
Assert(!view.Contains("data-location-bulk=\"create-all\"", StringComparison.Ordinal), "Location review should not offer bulk create-all.");
|
||||||
|
Assert(!view.Contains("data-location-bulk=\"ignore-all\"", StringComparison.Ordinal), "Location review should not offer bulk ignore-all.");
|
||||||
|
Assert(viewModels.Contains("IReadOnlyList<string> EvidenceSummaries", StringComparison.Ordinal), "Location candidates should carry aggregated evidence summaries.");
|
||||||
|
Assert(service.Contains("candidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID", StringComparison.Ordinal), "Location evidence should be deduplicated per scene/observation.");
|
||||||
|
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching.");
|
||||||
|
}
|
||||||
|
|
||||||
static bool IsMergedLocationPhrase(string name)
|
static bool IsMergedLocationPhrase(string name)
|
||||||
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
||||||
|
|
||||||
static bool IsStoryLocation(string name)
|
static bool IsStoryLocation(string name)
|
||||||
=> InvokePrivateLocationFilter("IsStoryLocation", name);
|
=> InvokePrivateLocationFilter("IsStoryLocation", name);
|
||||||
|
|
||||||
|
static bool IsGenericLocation(string name)
|
||||||
|
=> InvokePrivateLocationFilter("IsGenericLocation", name);
|
||||||
|
|
||||||
|
static bool IsGenericComposite(string name)
|
||||||
|
=> InvokePrivateLocationFilter("IsGenericComposite", name);
|
||||||
|
|
||||||
|
static IReadOnlySet<string> RepeatableGenericSettingKeys()
|
||||||
|
{
|
||||||
|
var field = typeof(StoryIntelligenceLocationImportService).GetField(
|
||||||
|
"RepeatableGenericSettings",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert(field is not null, "RepeatableGenericSettings was not found.");
|
||||||
|
return (IReadOnlySet<string>)field!.GetValue(null)!;
|
||||||
|
}
|
||||||
|
|
||||||
static string ResolveContextualLocationName(string name, string? genericRoomType, Scene scene)
|
static string ResolveContextualLocationName(string name, string? genericRoomType, Scene scene)
|
||||||
{
|
{
|
||||||
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
||||||
|
|||||||
@ -31,7 +31,8 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
"bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside",
|
"bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside",
|
||||||
"inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway",
|
"inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway",
|
||||||
"flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road",
|
"flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road",
|
||||||
"entrance", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden");
|
"entrance", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden",
|
||||||
|
"tunnel");
|
||||||
|
|
||||||
private static readonly HashSet<string> ThrowawayLocationFragments = CreateSet(
|
private static readonly HashSet<string> ThrowawayLocationFragments = CreateSet(
|
||||||
"door", "porch", "exterior", "tree", "pavement");
|
"door", "porch", "exterior", "tree", "pavement");
|
||||||
@ -43,21 +44,28 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|
|
||||||
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");
|
"seating area", "waiting room", "interview room", "staff room", "car park", "parking lot", "tunnel");
|
||||||
|
|
||||||
private static readonly HashSet<string> SingularisableLocationWords = CreateSet(
|
private static readonly HashSet<string> SingularisableLocationWords = CreateSet(
|
||||||
"garage", "road", "street", "room", "house", "flat", "bedroom", "kitchen", "garden", "office");
|
"garage", "road", "street", "room", "house", "flat", "bedroom", "kitchen", "garden", "office");
|
||||||
|
|
||||||
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
|
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
|
||||||
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
|
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
|
||||||
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "doweries");
|
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "inn", "theatre",
|
||||||
|
"cinema", "library", "museum", "club", "restaurant", "bar");
|
||||||
|
|
||||||
private static readonly HashSet<string> ConjunctionLocationWords = CreateSet(
|
private static readonly HashSet<string> ConjunctionLocationWords = CreateSet(
|
||||||
"shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden",
|
"shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden",
|
||||||
"rear garden", "front", "rear", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park");
|
"rear garden", "front", "rear", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street",
|
||||||
|
"car park", "flat", "home");
|
||||||
|
|
||||||
private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", 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 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 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);
|
||||||
|
|
||||||
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
@ -82,7 +90,24 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
||||||
ExampleContext = candidate.Description,
|
ExampleContext = candidate.Description,
|
||||||
ExistingLocationID = candidate.ExistingLocationID,
|
ExistingLocationID = candidate.ExistingLocationID,
|
||||||
ExistingLocationName = candidate.ExistingLocationName
|
ExistingLocationName = candidate.ExistingLocationName,
|
||||||
|
EvidenceSummaries = candidate.Appearances
|
||||||
|
.GroupBy(appearance => appearance.SceneID)
|
||||||
|
.OrderBy(group => group.Min(appearance => appearance.SceneNumber))
|
||||||
|
.Take(8)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
var observed = string.Join(", ", group
|
||||||
|
.Select(appearance => appearance.ObservedName)
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(3));
|
||||||
|
return string.IsNullOrWhiteSpace(observed)
|
||||||
|
? first.SceneReference
|
||||||
|
: $"{first.SceneReference}: {observed}";
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -382,36 +407,58 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var key = ResolveCandidateKey(groups, existingIndex, cleanName);
|
var identity = ResolveLocationIdentity(cleanName, existingIndex);
|
||||||
|
if (identity is null || !IsLocationNameCandidate(identity.DisplayName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = identity.Key;
|
||||||
if (!groups.TryGetValue(key, out var candidate))
|
if (!groups.TryGetValue(key, out var candidate))
|
||||||
{
|
{
|
||||||
var match = existingIndex.Find(cleanName);
|
var match = identity.ExistingMatch;
|
||||||
candidate = new LocationCandidate(key, cleanName, match?.LocationID, match?.LocationName)
|
candidate = new LocationCandidate(key, identity.DisplayName, match?.LocationID, match?.LocationName)
|
||||||
{
|
{
|
||||||
Category = Categorise(cleanName, locationType, genericRoomType),
|
Category = Categorise(identity.DisplayName, locationType, genericRoomType),
|
||||||
ParentLocationHint = Clean(parentLocationHint)
|
ParentLocationHint = Clean(parentLocationHint)
|
||||||
};
|
};
|
||||||
groups[key] = candidate;
|
groups[key] = candidate;
|
||||||
}
|
}
|
||||||
else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
|
if (!string.Equals(candidate.DisplayName, identity.DisplayName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
candidate.DisplayName = BestDisplayName(candidate.DisplayName, identity.DisplayName);
|
||||||
|
candidate.Aliases.Add(identity.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(identity.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
candidate.Aliases.Add(cleanName);
|
candidate.Aliases.Add(cleanName);
|
||||||
}
|
}
|
||||||
|
|
||||||
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, cleanName);
|
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, identity.DisplayName);
|
||||||
candidate.ParentLocationHint ??= Clean(parentLocationHint);
|
candidate.ParentLocationHint ??= Clean(parentLocationHint);
|
||||||
candidate.Appearances.Add(new LocationAppearanceImport(
|
var appearance = new LocationAppearanceImport(
|
||||||
importedScene.SceneID,
|
importedScene.SceneID,
|
||||||
importedScene.SceneNumber,
|
importedScene.SceneNumber,
|
||||||
importedScene.SceneTitle,
|
importedScene.SceneTitle,
|
||||||
|
BuildSceneReference(importedScene),
|
||||||
|
cleanName,
|
||||||
presentInScene && !mentionedOnly,
|
presentInScene && !mentionedOnly,
|
||||||
mentionedOnly,
|
mentionedOnly,
|
||||||
isSceneSetting,
|
isSceneSetting,
|
||||||
notes,
|
notes,
|
||||||
confidence,
|
confidence,
|
||||||
importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID));
|
importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID);
|
||||||
|
if (!candidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID
|
||||||
|
&& string.Equals(existing.ObservedName, appearance.ObservedName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(Clean(existing.Notes), Clean(appearance.Notes), StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
candidate.Appearances.Add(appearance);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
|
private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
|
||||||
@ -577,21 +624,25 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
{
|
{
|
||||||
var normalised = Normalise(name);
|
var normalised = Normalise(name);
|
||||||
var withoutParentheses = Normalise(Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty));
|
var withoutParentheses = Normalise(Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty));
|
||||||
|
var withoutArticle = Normalise(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name));
|
||||||
return GenericLocations.Contains(normalised)
|
return GenericLocations.Contains(normalised)
|
||||||
|| GenericLocations.Contains(withoutParentheses)
|
|| GenericLocations.Contains(withoutParentheses)
|
||||||
|
|| GenericLocations.Contains(withoutArticle)
|
||||||
|| InternalLocationWords.Contains(normalised)
|
|| InternalLocationWords.Contains(normalised)
|
||||||
|| InternalLocationWords.Contains(withoutParentheses);
|
|| InternalLocationWords.Contains(withoutParentheses)
|
||||||
|
|| InternalLocationWords.Contains(withoutArticle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsGenericComposite(string name)
|
private static bool IsGenericComposite(string name)
|
||||||
{
|
{
|
||||||
|
var clean = Clean(name);
|
||||||
var value = Normalise(name);
|
var value = Normalise(name);
|
||||||
if (!value.Contains('/') && !value.Contains(" / "))
|
return clean.Contains('/')
|
||||||
{
|
|| value.Contains(" then ", StringComparison.OrdinalIgnoreCase)
|
||||||
return false;
|
|| value.Contains(" onto ", StringComparison.OrdinalIgnoreCase)
|
||||||
}
|
|| value.Contains(" to ", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| value.Contains(" from ", StringComparison.OrdinalIgnoreCase)
|
||||||
return true;
|
|| IsMultiLocationConjunction(clean);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ContainsThrowawayFragment(string name)
|
private static bool ContainsThrowawayFragment(string name)
|
||||||
@ -602,13 +653,14 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|
|
||||||
private static bool IsMergedLocationPhrase(string name)
|
private static bool IsMergedLocationPhrase(string name)
|
||||||
{
|
{
|
||||||
var value = Normalise(StripParenthetical(name));
|
var clean = StripParenthetical(name);
|
||||||
|
var value = Normalise(clean);
|
||||||
if (!value.Contains(" and ", StringComparison.OrdinalIgnoreCase))
|
if (!value.Contains(" and ", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value is "rose and crown")
|
if (LooksLikeNamedCompound(clean))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -620,6 +672,41 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
private static bool ContainsConjunctionLocationWord(string value)
|
private static bool ContainsConjunctionLocationWord(string value)
|
||||||
=> ConjunctionLocationWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
|
=> ConjunctionLocationWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static bool LooksLikeNamedCompound(string value)
|
||||||
|
{
|
||||||
|
var clean = Clean(value);
|
||||||
|
if (!clean.Contains(" and ", StringComparison.OrdinalIgnoreCase) || !clean.Any(char.IsUpper))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = clean.Split(" and ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return parts.Length == 2
|
||||||
|
&& parts.All(part => part.Length > 1 && part.Any(char.IsUpper))
|
||||||
|
&& parts.All(part => !ConjunctionLocationWords.Contains(Normalise(part)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMultiLocationConjunction(string value)
|
||||||
|
{
|
||||||
|
var clean = Clean(value);
|
||||||
|
if (!clean.Contains(" and ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = clean.Split(" and ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return parts.Length > 1
|
||||||
|
&& parts.Count(part => IsNamedLocation(StripLeadingContextPhrases(part))) > 1
|
||||||
|
&& parts.Any(part => ContainsConjunctionLocationWord(Normalise(part)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBarePossessiveReference(string value)
|
||||||
|
{
|
||||||
|
var words = Normalise(value).Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return words.Length <= 2
|
||||||
|
&& Regex.IsMatch(Clean(value), @"'\s*s$", RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool HasUsefulParentHint(string? parentHint)
|
private static bool HasUsefulParentHint(string? parentHint)
|
||||||
{
|
{
|
||||||
var clean = Clean(parentHint);
|
var clean = Clean(parentHint);
|
||||||
@ -638,6 +725,11 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsBarePossessiveReference(clean))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (AddressPattern.IsMatch(clean) || IsNamedRoad(clean))
|
if (AddressPattern.IsMatch(clean) || IsNamedRoad(clean))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@ -652,6 +744,11 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
private static bool IsNamedLocation(string name)
|
private static bool IsNamedLocation(string name)
|
||||||
{
|
{
|
||||||
var withoutParentheses = StripParenthetical(name);
|
var withoutParentheses = StripParenthetical(name);
|
||||||
|
if (IsBarePossessiveReference(withoutParentheses))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return AddressPattern.IsMatch(withoutParentheses)
|
return AddressPattern.IsMatch(withoutParentheses)
|
||||||
|| IsNamedRoad(name)
|
|| IsNamedRoad(name)
|
||||||
|| withoutParentheses.Any(char.IsUpper)
|
|| withoutParentheses.Any(char.IsUpper)
|
||||||
@ -776,10 +873,189 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|
|
||||||
private static string CanonicalLocationKey(string name)
|
private static string CanonicalLocationKey(string name)
|
||||||
{
|
{
|
||||||
var clean = StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(StripParenthetical(CleanLocationName(name)));
|
var clean = StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(NormaliseLocationIdentityName(name));
|
||||||
return StoryIntelligenceEntityTextNormaliser.SingulariseSafeFinalWord(clean, SingularisableLocationWords);
|
return StoryIntelligenceEntityTextNormaliser.SingulariseSafeFinalWord(clean, SingularisableLocationWords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static LocationIdentity? ResolveLocationIdentity(string name, LocationIndex existingIndex)
|
||||||
|
{
|
||||||
|
var clean = CleanLocationName(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(clean))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingMatch = existingIndex.Find(clean);
|
||||||
|
if (existingMatch is not null)
|
||||||
|
{
|
||||||
|
return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
var identityName = NormaliseLocationIdentityName(clean);
|
||||||
|
existingMatch = existingIndex.Find(identityName);
|
||||||
|
if (existingMatch is not null)
|
||||||
|
{
|
||||||
|
return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LocationIdentity(CanonicalLocationKey(identityName), identityName, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormaliseLocationIdentityName(string? value)
|
||||||
|
{
|
||||||
|
var clean = CleanLocationName(value);
|
||||||
|
clean = PossessiveControlCharacter.Replace(clean, "'");
|
||||||
|
clean = ControlCharacters.Replace(clean, " ");
|
||||||
|
clean = CleanLocationName(clean);
|
||||||
|
clean = StripParenthetical(clean);
|
||||||
|
clean = StripLeadingContextPhrases(clean);
|
||||||
|
clean = StripTemporalSuffix(clean);
|
||||||
|
clean = PreferSingleIdentitySegment(clean);
|
||||||
|
clean = StripLowValueContextSuffix(clean);
|
||||||
|
return CleanLocationName(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripLeadingContextPhrases(string value)
|
||||||
|
{
|
||||||
|
var clean = Clean(value);
|
||||||
|
string previous;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
previous = clean;
|
||||||
|
clean = LeadingContextPhrase.Replace(clean, string.Empty);
|
||||||
|
} while (!string.Equals(previous, clean, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
return Clean(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripTemporalSuffix(string value)
|
||||||
|
=> Clean(TemporalSuffix.Replace(value, string.Empty));
|
||||||
|
|
||||||
|
private static string PreferSingleIdentitySegment(string value)
|
||||||
|
{
|
||||||
|
var clean = Clean(value);
|
||||||
|
if (Regex.IsMatch(clean, @"\s*/\s*|;|\bthen\b", RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
var parts = Regex.Split(clean, @"\s*/\s*|;|\bthen\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)
|
||||||
|
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||||
|
.Select(part => part.Trim())
|
||||||
|
.ToArray();
|
||||||
|
var named = parts
|
||||||
|
.Select(StripLeadingContextPhrases)
|
||||||
|
.Select(StripTemporalSuffix)
|
||||||
|
.Select(StripLowValueContextSuffix)
|
||||||
|
.Where(IsNamedLocation)
|
||||||
|
.OrderByDescending(LocationNameQualityScore)
|
||||||
|
.ThenBy(part => part.Length)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(named))
|
||||||
|
{
|
||||||
|
return named;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Regex.Replace(clean, @"\s+and\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripLowValueContextSuffix(string value)
|
||||||
|
{
|
||||||
|
var clean = Clean(value);
|
||||||
|
var patterns = new[]
|
||||||
|
{
|
||||||
|
@"\s+interior\b.*$",
|
||||||
|
@"\s+exterior\b.*$",
|
||||||
|
@"\s+inside\b.*$",
|
||||||
|
@"\s+outside\b.*$",
|
||||||
|
@"\s+main\s+room\b.*$",
|
||||||
|
@"\s+bar\s+area\b.*$",
|
||||||
|
@"\s+table\s+and\s+bar\b.*$",
|
||||||
|
@"\s+dancefloor\s+and\s+bar\s+area\b.*$",
|
||||||
|
@"\s+dance\s+floor\s+and\s+bar\s+area\b.*$",
|
||||||
|
@"\s+and\s+driveway\b.*$",
|
||||||
|
@"\s+and\s+walk\s+to\b.*$",
|
||||||
|
@"\s+en\s+route\b.*$",
|
||||||
|
@"\s+and\s+outside\b.*$",
|
||||||
|
@"\s+and\s+inside\b.*$"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pattern in patterns)
|
||||||
|
{
|
||||||
|
var candidate = Regex.Replace(clean, pattern, string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||||
|
if (!string.Equals(candidate, clean, StringComparison.Ordinal)
|
||||||
|
&& !string.IsNullOrWhiteSpace(candidate)
|
||||||
|
&& IsNamedLocation(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BestDisplayName(string current, string next)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(current))
|
||||||
|
{
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(next))
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentScore = LocationNameQualityScore(current);
|
||||||
|
var nextScore = LocationNameQualityScore(next);
|
||||||
|
if (nextScore != currentScore)
|
||||||
|
{
|
||||||
|
return nextScore > currentScore ? next : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.Length < current.Length ? next : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LocationNameQualityScore(string value)
|
||||||
|
{
|
||||||
|
var clean = Clean(value);
|
||||||
|
var normalised = Normalise(clean);
|
||||||
|
var score = 0;
|
||||||
|
if (clean.Any(char.IsUpper))
|
||||||
|
{
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsStoryLocation(clean))
|
||||||
|
{
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clean.Contains('\'') || clean.Contains('’'))
|
||||||
|
{
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsGenericLocation(clean))
|
||||||
|
{
|
||||||
|
score -= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalised.Contains(" and ", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| clean.Contains('/')
|
||||||
|
|| TemporalSuffix.IsMatch(clean)
|
||||||
|
|| LeadingContextPhrase.IsMatch(clean))
|
||||||
|
{
|
||||||
|
score -= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalised.Contains("interior", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| normalised.Contains("exterior", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
private static string ResolveContextualLocationName(string? name, string? genericRoomType, Scene scene)
|
private static string ResolveContextualLocationName(string? name, string? genericRoomType, Scene scene)
|
||||||
{
|
{
|
||||||
var clean = CleanLocationName(name);
|
var clean = CleanLocationName(name);
|
||||||
@ -823,10 +1099,15 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string StripParenthetical(string? value)
|
private static string StripParenthetical(string? value)
|
||||||
=> Regex.Replace(Clean(value), @"\s*\([^)]*\)\s*$", string.Empty);
|
=> ParentheticalSuffix.Replace(Clean(value), string.Empty);
|
||||||
|
|
||||||
private static string CleanLocationName(string? value)
|
private static string CleanLocationName(string? value)
|
||||||
=> StoryIntelligenceEntityTextNormaliser.CleanName(value);
|
{
|
||||||
|
var clean = StoryIntelligenceEntityTextNormaliser.CleanName(value);
|
||||||
|
clean = PossessiveControlCharacter.Replace(clean, "'");
|
||||||
|
clean = ControlCharacters.Replace(clean, " ");
|
||||||
|
return StoryIntelligenceEntityTextNormaliser.CleanName(clean);
|
||||||
|
}
|
||||||
|
|
||||||
private static string Normalise(string? value)
|
private static string Normalise(string? value)
|
||||||
=> StoryIntelligenceEntityTextNormaliser.SafeKey(value);
|
=> StoryIntelligenceEntityTextNormaliser.SafeKey(value);
|
||||||
@ -859,7 +1140,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
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; } = displayName;
|
public string DisplayName { get; set; } = displayName;
|
||||||
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";
|
||||||
@ -875,6 +1156,8 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
int SceneID,
|
int SceneID,
|
||||||
decimal SceneNumber,
|
decimal SceneNumber,
|
||||||
string SceneTitle,
|
string SceneTitle,
|
||||||
|
string SceneReference,
|
||||||
|
string ObservedName,
|
||||||
bool PresentInScene,
|
bool PresentInScene,
|
||||||
bool MentionedOnly,
|
bool MentionedOnly,
|
||||||
bool IsSceneSetting,
|
bool IsSceneSetting,
|
||||||
@ -882,6 +1165,8 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
decimal? Confidence,
|
decimal? Confidence,
|
||||||
bool AlreadyLinked);
|
bool AlreadyLinked);
|
||||||
|
|
||||||
|
private sealed record LocationIdentity(string Key, string DisplayName, LocationMatch? ExistingMatch);
|
||||||
|
|
||||||
private sealed record LocationCandidateData(
|
private sealed record LocationCandidateData(
|
||||||
bool HasCommittedScenes,
|
bool HasCommittedScenes,
|
||||||
int AlreadyLinkedCount,
|
int AlreadyLinkedCount,
|
||||||
|
|||||||
@ -391,6 +391,7 @@ public sealed class StoryIntelligenceLocationReviewCandidateViewModel
|
|||||||
public string? ParentLocationHint { get; init; }
|
public string? ParentLocationHint { 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 int? ExistingLocationID { get; init; }
|
public int? ExistingLocationID { get; init; }
|
||||||
public string? ExistingLocationName { get; init; }
|
public string? ExistingLocationName { get; init; }
|
||||||
public bool IsExistingMatch => ExistingLocationID.HasValue;
|
public bool IsExistingMatch => ExistingLocationID.HasValue;
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
@model StoryIntelligenceProgressViewModel
|
@model StoryIntelligenceProgressViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Review locations";
|
ViewData["Title"] = "Review locations";
|
||||||
const int previewLimit = 50;
|
var locationCandidates = Model.LocationReview.Candidates.ToList();
|
||||||
var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase);
|
|
||||||
var locationCandidates = showAll
|
|
||||||
? Model.LocationReview.Candidates.ToList()
|
|
||||||
: Model.LocationReview.Candidates.Take(previewLimit).ToList();
|
|
||||||
var hiddenCandidateCount = Math.Max(0, Model.LocationReview.Candidates.Count - locationCandidates.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="story-location-title">
|
<section class="onboarding-shell" aria-labelledby="story-location-title">
|
||||||
@ -40,24 +35,42 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="story-review-card-actions">
|
<div class="story-review-card-actions">
|
||||||
@if (hiddenCandidateCount > 0)
|
|
||||||
{
|
|
||||||
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.LocationReview.Candidates.Count.ToString("N0")</a>
|
|
||||||
}
|
|
||||||
<button class="btn btn-outline-primary btn-sm" type="button" data-location-bulk="create-all">Create all</button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Link existing matches</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Link existing matches</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="ignore-all">Ignore all</button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
|
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
|
||||||
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
||||||
|
<div class="story-review-filters" aria-label="Location candidate filters">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="location-candidate-search">Search</label>
|
||||||
|
<input id="location-candidate-search" class="form-control" type="search" placeholder="Search locations or matches" data-location-search />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="location-candidate-status">Filter</label>
|
||||||
|
<select id="location-candidate-status" class="form-select" data-location-status-filter>
|
||||||
|
<option value="needs-review">Needs review</option>
|
||||||
|
<option value="existing">Existing matches</option>
|
||||||
|
<option value="new">New locations</option>
|
||||||
|
<option value="all">All visible candidates</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="story-review-page-status" data-location-page-status>
|
||||||
|
Showing @Math.Min(locationCandidates.Count, 24).ToString("N0") of @locationCandidates.Count.ToString("N0")
|
||||||
|
</div>
|
||||||
|
<div class="story-review-pager">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-page-prev>Previous</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-page-next>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="story-character-card-grid">
|
<div class="story-character-card-grid">
|
||||||
@for (var i = 0; i < locationCandidates.Count; i++)
|
@for (var i = 0; i < locationCandidates.Count; i++)
|
||||||
{
|
{
|
||||||
var candidate = locationCandidates[i];
|
var candidate = locationCandidates[i];
|
||||||
<details class="story-character-card" open data-location-card>
|
var status = candidate.IsExistingMatch ? "existing" : "new";
|
||||||
|
var searchText = string.Join(" ", new[] { candidate.LocationName, candidate.ExistingLocationName, candidate.Category, candidate.ParentLocationHint });
|
||||||
|
<details class="story-character-card" open data-location-card data-location-status="@status" data-location-search-text="@searchText">
|
||||||
<summary>
|
<summary>
|
||||||
<span>
|
<span>
|
||||||
<strong>@candidate.LocationName</strong>
|
<strong>@candidate.LocationName</strong>
|
||||||
@ -83,6 +96,19 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (candidate.EvidenceSummaries.Any())
|
||||||
|
{
|
||||||
|
<details class="story-review-note">
|
||||||
|
<summary>Evidence from @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</summary>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var evidence in candidate.EvidenceSummaries)
|
||||||
|
{
|
||||||
|
<li>@evidence</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
|
||||||
@if (candidate.IsExistingMatch)
|
@if (candidate.IsExistingMatch)
|
||||||
{
|
{
|
||||||
<div class="story-review-note">
|
<div class="story-review-note">
|
||||||
@ -132,10 +158,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
@if (hiddenCandidateCount > 0)
|
|
||||||
{
|
|
||||||
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
|
|
||||||
}
|
|
||||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||||||
<button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
|
<button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
|
||||||
</div>
|
</div>
|
||||||
@ -151,7 +173,14 @@
|
|||||||
const form = document.querySelector("[data-location-review-form]");
|
const form = document.querySelector("[data-location-review-form]");
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
|
const pageSize = 24;
|
||||||
|
let currentPage = 1;
|
||||||
const cards = () => Array.from(form.querySelectorAll("[data-location-card]"));
|
const cards = () => Array.from(form.querySelectorAll("[data-location-card]"));
|
||||||
|
const search = form.querySelector("[data-location-search]");
|
||||||
|
const statusFilter = form.querySelector("[data-location-status-filter]");
|
||||||
|
const pageStatus = form.querySelector("[data-location-page-status]");
|
||||||
|
const previousPage = form.querySelector("[data-location-page-prev]");
|
||||||
|
const nextPage = form.querySelector("[data-location-page-next]");
|
||||||
const setAction = (card, action) => {
|
const setAction = (card, action) => {
|
||||||
const input = card.querySelector(`[data-location-action][value="${action}"]`);
|
const input = card.querySelector(`[data-location-action][value="${action}"]`);
|
||||||
if (input) {
|
if (input) {
|
||||||
@ -193,24 +222,57 @@
|
|||||||
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
|
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
|
||||||
updateAliasTargets();
|
updateAliasTargets();
|
||||||
};
|
};
|
||||||
|
const matchesFilters = (card) => {
|
||||||
|
const term = (search?.value || "").trim().toLocaleLowerCase();
|
||||||
|
const status = statusFilter?.value || "needs-review";
|
||||||
|
const cardStatus = card.getAttribute("data-location-status") || "new";
|
||||||
|
const haystack = (card.getAttribute("data-location-search-text") || "").toLocaleLowerCase();
|
||||||
|
if (term && !haystack.includes(term)) return false;
|
||||||
|
if (status === "existing" && cardStatus !== "existing") return false;
|
||||||
|
if (status === "new" && cardStatus !== "new") return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const renderPage = () => {
|
||||||
|
const allCards = cards();
|
||||||
|
const filtered = allCards.filter(matchesFilters);
|
||||||
|
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||||
|
if (currentPage > pages) currentPage = pages;
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
const visible = new Set(filtered.slice(start, start + pageSize));
|
||||||
|
for (const card of allCards) {
|
||||||
|
card.hidden = !visible.has(card);
|
||||||
|
}
|
||||||
|
if (pageStatus) {
|
||||||
|
const end = filtered.length === 0 ? 0 : Math.min(filtered.length, start + pageSize);
|
||||||
|
pageStatus.textContent = filtered.length === 0
|
||||||
|
? "No matching location candidates"
|
||||||
|
: `Showing ${start + 1}-${end} of ${filtered.length} location candidates · Page ${currentPage} of ${pages}`;
|
||||||
|
}
|
||||||
|
if (previousPage) previousPage.disabled = currentPage <= 1;
|
||||||
|
if (nextPage) nextPage.disabled = currentPage >= pages;
|
||||||
|
};
|
||||||
document.querySelectorAll("[data-location-bulk]").forEach(button => {
|
document.querySelectorAll("[data-location-bulk]").forEach(button => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const action = button.getAttribute("data-location-bulk");
|
const action = button.getAttribute("data-location-bulk");
|
||||||
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceLocationImportActions.CreateNew"));
|
|
||||||
if (action === "link-existing") cards().forEach(card => {
|
if (action === "link-existing") cards().forEach(card => {
|
||||||
if (card.querySelector(`[data-location-action][value="@StoryIntelligenceLocationImportActions.LinkExisting"]:not(:disabled)`)) {
|
if (card.querySelector(`[data-location-action][value="@StoryIntelligenceLocationImportActions.LinkExisting"]:not(:disabled)`)) {
|
||||||
setAction(card, "@StoryIntelligenceLocationImportActions.LinkExisting");
|
setAction(card, "@StoryIntelligenceLocationImportActions.LinkExisting");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceLocationImportActions.Ignore"));
|
|
||||||
if (action === "expand") cards().forEach(card => card.open = true);
|
if (action === "expand") cards().forEach(card => card.open = true);
|
||||||
if (action === "collapse") cards().forEach(card => card.open = false);
|
if (action === "collapse") cards().forEach(card => card.open = false);
|
||||||
|
renderPage();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
previousPage?.addEventListener("click", () => { currentPage = Math.max(1, currentPage - 1); renderPage(); });
|
||||||
|
nextPage?.addEventListener("click", () => { currentPage += 1; renderPage(); });
|
||||||
|
search?.addEventListener("input", () => { currentPage = 1; renderPage(); });
|
||||||
|
statusFilter?.addEventListener("change", () => { currentPage = 1; renderPage(); });
|
||||||
form.querySelectorAll("[data-location-action]").forEach(input => {
|
form.querySelectorAll("[data-location-action]").forEach(input => {
|
||||||
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
|
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
|
||||||
});
|
});
|
||||||
cards().forEach(updateCard);
|
cards().forEach(updateCard);
|
||||||
|
renderPage();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
|
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
|
||||||
|
|||||||
@ -1269,6 +1269,41 @@ summary.story-review-chapter-heading {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.story-review-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(14rem, 1fr) minmax(12rem, 16rem) auto auto;
|
||||||
|
gap: .75rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-review-page-status {
|
||||||
|
align-self: center;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-review-pager {
|
||||||
|
display: flex;
|
||||||
|
gap: .45rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.story-review-filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-review-page-status,
|
||||||
|
.story-review-pager {
|
||||||
|
justify-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-review-pager {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.story-pipeline-stepper small {
|
.story-pipeline-stepper small {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user