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 contextual names use POV and structural setting", LocationContextualNamesUsePovAndStructuralSetting),
|
||||
("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals),
|
||||
("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants),
|
||||
("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets),
|
||||
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
|
||||
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
|
||||
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
||||
@ -897,6 +899,14 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering()
|
||||
foreach (var viewName in heavyViews)
|
||||
{
|
||||
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("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.");
|
||||
@ -1927,7 +1937,6 @@ static void LocationFilteringRejectsMergedPhrases()
|
||||
|
||||
static void LocationFilteringPreservesStoryLocations()
|
||||
{
|
||||
Assert(IsStoryLocation("The Doweries"), "The Doweries 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("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.");
|
||||
}
|
||||
|
||||
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)
|
||||
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
||||
|
||||
static bool IsStoryLocation(string 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)
|
||||
{
|
||||
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
||||
|
||||
@ -31,7 +31,8 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
"bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside",
|
||||
"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",
|
||||
"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(
|
||||
"door", "porch", "exterior", "tree", "pavement");
|
||||
@ -43,21 +44,28 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
|
||||
private static readonly HashSet<string> RepeatableGenericSettings = CreateSet(
|
||||
"kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage",
|
||||
"seating area", "waiting room", "interview room", "staff room", "car park", "parking lot");
|
||||
"seating area", "waiting room", "interview room", "staff room", "car park", "parking lot", "tunnel");
|
||||
|
||||
private static readonly HashSet<string> SingularisableLocationWords = CreateSet(
|
||||
"garage", "road", "street", "room", "house", "flat", "bedroom", "kitchen", "garden", "office");
|
||||
|
||||
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
|
||||
"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(
|
||||
"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 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)
|
||||
{
|
||||
@ -82,7 +90,24 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
||||
ExampleContext = candidate.Description,
|
||||
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()
|
||||
};
|
||||
}
|
||||
@ -382,36 +407,58 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
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))
|
||||
{
|
||||
var match = existingIndex.Find(cleanName);
|
||||
candidate = new LocationCandidate(key, cleanName, match?.LocationID, match?.LocationName)
|
||||
var match = identity.ExistingMatch;
|
||||
candidate = new LocationCandidate(key, identity.DisplayName, match?.LocationID, match?.LocationName)
|
||||
{
|
||||
Category = Categorise(cleanName, locationType, genericRoomType),
|
||||
Category = Categorise(identity.DisplayName, locationType, genericRoomType),
|
||||
ParentLocationHint = Clean(parentLocationHint)
|
||||
};
|
||||
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.Confidence = Max(candidate.Confidence, confidence);
|
||||
candidate.Description ??= Clean(notes);
|
||||
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName);
|
||||
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, identity.DisplayName);
|
||||
candidate.ParentLocationHint ??= Clean(parentLocationHint);
|
||||
candidate.Appearances.Add(new LocationAppearanceImport(
|
||||
var appearance = new LocationAppearanceImport(
|
||||
importedScene.SceneID,
|
||||
importedScene.SceneNumber,
|
||||
importedScene.SceneTitle,
|
||||
BuildSceneReference(importedScene),
|
||||
cleanName,
|
||||
presentInScene && !mentionedOnly,
|
||||
mentionedOnly,
|
||||
isSceneSetting,
|
||||
notes,
|
||||
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)
|
||||
@ -577,21 +624,25 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
{
|
||||
var normalised = Normalise(name);
|
||||
var withoutParentheses = Normalise(Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty));
|
||||
var withoutArticle = Normalise(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name));
|
||||
return GenericLocations.Contains(normalised)
|
||||
|| GenericLocations.Contains(withoutParentheses)
|
||||
|| GenericLocations.Contains(withoutArticle)
|
||||
|| InternalLocationWords.Contains(normalised)
|
||||
|| InternalLocationWords.Contains(withoutParentheses);
|
||||
|| InternalLocationWords.Contains(withoutParentheses)
|
||||
|| InternalLocationWords.Contains(withoutArticle);
|
||||
}
|
||||
|
||||
private static bool IsGenericComposite(string name)
|
||||
{
|
||||
var clean = Clean(name);
|
||||
var value = Normalise(name);
|
||||
if (!value.Contains('/') && !value.Contains(" / "))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return clean.Contains('/')
|
||||
|| value.Contains(" then ", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains(" onto ", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains(" to ", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains(" from ", StringComparison.OrdinalIgnoreCase)
|
||||
|| IsMultiLocationConjunction(clean);
|
||||
}
|
||||
|
||||
private static bool ContainsThrowawayFragment(string name)
|
||||
@ -602,13 +653,14 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
|
||||
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))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value is "rose and crown")
|
||||
if (LooksLikeNamedCompound(clean))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@ -620,6 +672,41 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
private static bool ContainsConjunctionLocationWord(string value)
|
||||
=> 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)
|
||||
{
|
||||
var clean = Clean(parentHint);
|
||||
@ -638,6 +725,11 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsBarePossessiveReference(clean))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (AddressPattern.IsMatch(clean) || IsNamedRoad(clean))
|
||||
{
|
||||
return true;
|
||||
@ -652,6 +744,11 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
private static bool IsNamedLocation(string name)
|
||||
{
|
||||
var withoutParentheses = StripParenthetical(name);
|
||||
if (IsBarePossessiveReference(withoutParentheses))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return AddressPattern.IsMatch(withoutParentheses)
|
||||
|| IsNamedRoad(name)
|
||||
|| withoutParentheses.Any(char.IsUpper)
|
||||
@ -776,10 +873,189 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
|
||||
private static string CanonicalLocationKey(string name)
|
||||
{
|
||||
var clean = StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(StripParenthetical(CleanLocationName(name)));
|
||||
var clean = StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(NormaliseLocationIdentityName(name));
|
||||
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)
|
||||
{
|
||||
var clean = CleanLocationName(name);
|
||||
@ -823,10 +1099,15 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
}
|
||||
|
||||
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)
|
||||
=> 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)
|
||||
=> StoryIntelligenceEntityTextNormaliser.SafeKey(value);
|
||||
@ -859,7 +1140,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName)
|
||||
{
|
||||
public string Key { get; } = key;
|
||||
public string DisplayName { get; } = displayName;
|
||||
public string DisplayName { get; set; } = displayName;
|
||||
public int? ExistingLocationID { get; } = existingLocationId;
|
||||
public string? ExistingLocationName { get; } = existingLocationName;
|
||||
public string Category { get; set; } = "Ambiguous location";
|
||||
@ -875,6 +1156,8 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
int SceneID,
|
||||
decimal SceneNumber,
|
||||
string SceneTitle,
|
||||
string SceneReference,
|
||||
string ObservedName,
|
||||
bool PresentInScene,
|
||||
bool MentionedOnly,
|
||||
bool IsSceneSetting,
|
||||
@ -882,6 +1165,8 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
decimal? Confidence,
|
||||
bool AlreadyLinked);
|
||||
|
||||
private sealed record LocationIdentity(string Key, string DisplayName, LocationMatch? ExistingMatch);
|
||||
|
||||
private sealed record LocationCandidateData(
|
||||
bool HasCommittedScenes,
|
||||
int AlreadyLinkedCount,
|
||||
|
||||
@ -391,6 +391,7 @@ public sealed class StoryIntelligenceLocationReviewCandidateViewModel
|
||||
public string? ParentLocationHint { get; init; }
|
||||
public string? ExampleFirstAppearance { get; init; }
|
||||
public string? ExampleContext { get; init; }
|
||||
public IReadOnlyList<string> EvidenceSummaries { get; init; } = [];
|
||||
public int? ExistingLocationID { get; init; }
|
||||
public string? ExistingLocationName { get; init; }
|
||||
public bool IsExistingMatch => ExistingLocationID.HasValue;
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
@model StoryIntelligenceProgressViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Review locations";
|
||||
const int previewLimit = 50;
|
||||
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);
|
||||
var locationCandidates = Model.LocationReview.Candidates.ToList();
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-location-title">
|
||||
@ -40,24 +35,42 @@
|
||||
else
|
||||
{
|
||||
<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="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="collapse">Collapse all</button>
|
||||
</div>
|
||||
|
||||
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
|
||||
<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">
|
||||
@for (var i = 0; i < locationCandidates.Count; 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>
|
||||
<span>
|
||||
<strong>@candidate.LocationName</strong>
|
||||
@ -83,6 +96,19 @@
|
||||
</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)
|
||||
{
|
||||
<div class="story-review-note">
|
||||
@ -132,10 +158,6 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
|
||||
</div>
|
||||
@ -151,7 +173,14 @@
|
||||
const form = document.querySelector("[data-location-review-form]");
|
||||
if (!form) return;
|
||||
|
||||
const pageSize = 24;
|
||||
let currentPage = 1;
|
||||
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 input = card.querySelector(`[data-location-action][value="${action}"]`);
|
||||
if (input) {
|
||||
@ -193,24 +222,57 @@
|
||||
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
|
||||
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 => {
|
||||
button.addEventListener("click", () => {
|
||||
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 (card.querySelector(`[data-location-action][value="@StoryIntelligenceLocationImportActions.LinkExisting"]:not(:disabled)`)) {
|
||||
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 === "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 => {
|
||||
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
|
||||
});
|
||||
cards().forEach(updateCard);
|
||||
renderPage();
|
||||
})();
|
||||
</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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: block;
|
||||
color: var(--bs-secondary-color);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user