Consolidate Story Intelligence location candidates

This commit is contained in:
Nick Beckley 2026-08-29 12:24:45 +00:00
parent 6dec8a4345
commit b7f24954e1
5 changed files with 492 additions and 46 deletions

View File

@ -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(

View File

@ -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,

View File

@ -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;

View File

@ -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>

View File

@ -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);