using System.Text.Json; using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceLocationImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form); } public sealed class StoryIntelligenceLocationImportService( IStoryIntelligenceResultRepository runs, ILocationRepository locations, ISceneRepository scenes, IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceLocationImportService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; private static readonly HashSet GenericLocations = CreateSet( "room", "place", "area", "building", "street", "road", "door", "doorway", "wall", "floor", "pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen", "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", "tunnel"); private static readonly HashSet ThrowawayLocationFragments = CreateSet( "door", "porch", "exterior", "tree", "pavement"); private static readonly HashSet InternalLocationWords = CreateSet( "room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs", "landing", "doorway", "pew", "altar", "floor", "entrance", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden", "staff room", "lift"); private static readonly HashSet RepeatableGenericSettings = CreateSet( "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "staff room", "car park", "parking lot", "tunnel"); private static readonly HashSet SingularisableLocationWords = CreateSet( "garage", "road", "street", "room", "house", "flat", "bedroom", "kitchen", "garden", "office"); private static readonly HashSet StoryLocationSignals = CreateSet( "trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub", "cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "inn", "theatre", "cinema", "library", "museum", "club", "restaurant", "bar"); private static readonly HashSet 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", "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 BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); if (batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null) { return new StoryIntelligenceLocationReviewViewModel { HasCommittedScenes = true, CanImport = false, IsComplete = true, Candidates = [] }; } var data = await BuildCandidateDataAsync(batch); var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceLocationReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel { Key = candidate.Key, LocationName = candidate.DisplayName, ImportName = candidate.DisplayName, Category = candidate.Category, AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), Confidence = ConfidenceBand(candidate.Confidence), ParentLocationHint = candidate.ParentLocationHint, ExampleFirstAppearance = candidate.FirstAppearanceNote, ExampleContext = candidate.Description, ExistingLocationID = candidate.ExistingLocationID, 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() }; } public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing locations." }; } var choices = form.Locations .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); if (choices.Count == 0) { return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one location to create, link or ignore." }; } var existingIndex = await BuildLocationIndexAsync(batch.ProjectID); var lookupData = await locations.GetLookupsAsync(); var created = 0; var linkedExisting = 0; var ignored = 0; var aliasesAdded = 0; var sceneLocationsUpdated = 0; var resolvedNames = new Dictionary(StringComparer.OrdinalIgnoreCase); var resolvedCandidateIds = new Dictionary(StringComparer.OrdinalIgnoreCase); var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase); foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice)) { continue; } if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) { ignored++; AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Ignore, candidate.DisplayName, null, false); continue; } if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)) { continue; } var requestedName = Clean(choice.ImportName); var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; var linkExisting = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); var matchedExistingId = linkExisting ? candidate.ExistingLocationID ?? existingIndex.Find(importName)?.LocationID ?? existingIndex.Find(candidate.DisplayName)?.LocationID : null; var locationId = matchedExistingId; if (!locationId.HasValue) { var parentId = ResolveParentLocationId(candidate.ParentLocationHint, existingIndex); locationId = await locations.SaveAsync(new LocationItem { ProjectID = batch.ProjectID, ParentLocationID = parentId, LocationName = importName, LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate), Description = BuildLocationDescription(candidate), DetectionPriority = 50 }); created++; } else { linkedExisting++; } AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value); AddResolvedName(resolvedNames, importName, locationId.Value); resolvedCandidateIds[candidate.Key] = locationId.Value; AddDecision(batch, candidate.Key, choice.Action, importName, locationId.Value, true); } foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice) || !string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)) { continue; } var targetKey = Clean(choice.AliasTargetKey); if (string.IsNullOrWhiteSpace(targetKey) || string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase) || !choices.TryGetValue(targetKey, out var targetChoice) || string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase) || string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase) || !candidateByKey.TryGetValue(targetKey, out var targetCandidate) || !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId)) { logger.LogWarning( "Story Intelligence location alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.", candidate.Key, targetKey); continue; } var targetName = targetCandidate.ExistingLocationName ?? targetChoice.ImportName ?? targetCandidate.DisplayName; if (await TryAddAliasAsync(targetLocationId, candidate.DisplayName, targetName)) { aliasesAdded++; } AddResolvedName(resolvedNames, candidate.DisplayName, targetLocationId); AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Alias, candidate.DisplayName, targetLocationId, true); } foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice) || string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) { continue; } var locationId = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase) ? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) : resolvedCandidateIds.GetValueOrDefault(candidate.Key); if (locationId <= 0) { continue; } foreach (var appearance in candidate.Appearances.Where(appearance => appearance.PresentInScene && !appearance.AlreadyLinked)) { var scene = await scenes.GetAsync(appearance.SceneID); if (scene is null || scene.PrimaryLocationID.HasValue) { continue; } scene.PrimaryLocationID = locationId; await scenes.SaveAsync(scene); sceneLocationsUpdated++; } } batch.LocationStageComplete = true; batch.LastLocationImportResult = new StoryIntelligenceLocationImportBatchResult { LocationsCreated = created, LocationsLinked = linkedExisting, LocationAliasesAdded = aliasesAdded, LocationsIgnored = ignored, SceneLocationsUpdated = sceneLocationsUpdated }; await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID); logger.LogInformation( "Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}", batch.BatchID, created, linkedExisting, aliasesAdded, ignored, sceneLocationsUpdated); return new StoryIntelligenceImportCommitResult { Success = true, ScenesCreated = sceneLocationsUpdated, Message = $"Locations imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneLocationsUpdated:N0} scene location(s) updated." }; } private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) { var existingIndex = await BuildLocationIndexAsync(batch.ProjectID); var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); var alreadyLinked = 0; var hasCommittedScenes = false; foreach (var item in batch.Items) { var run = await runs.GetRunAsync(item.RunID); if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) { continue; } var sceneResults = await runs.ListSceneResultsAsync(item.RunID); if (sceneResults.Count == 0) { continue; } hasCommittedScenes = true; var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue) ? chapterScenes : chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList(); var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene); var importedByRange = importedScenes .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); foreach (var sceneResult in sceneResults) { var parsed = TryReadScene(sceneResult); if (parsed is null) { continue; } var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber); if (importedScene is null) { continue; } if (importedScene.PrimaryLocationID.HasValue) { alreadyLinked++; } AddSceneLocations(groups, existingIndex, importedScene, parsed); } } var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var candidates = groups.Values .Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked)) .Where(candidate => IsVisibleLocationCandidate(candidate)) .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count()) .ThenBy(candidate => candidate.DisplayName) .ToList(); return new LocationCandidateData(hasCommittedScenes, alreadyLinked, candidates, decidedKeys); } private static void AddSceneLocations( Dictionary groups, LocationIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed) { var structuralSetting = Clean(importedScene.StructuralSetting); if (!string.IsNullOrWhiteSpace(structuralSetting)) { AddLocation(groups, existingIndex, importedScene, parsed, structuralSetting, "named", null, null, true, false, true, 0.95m, "Structural setting from Core Import."); } var setting = parsed.Setting; var settingName = ResolveContextualLocationName(FirstConfigured(setting?.LocationName, setting?.GenericRoomType), setting?.GenericRoomType, importedScene); AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting"); foreach (var location in parsed.Locations ?? []) { AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(location.Name, location.GenericRoomType, importedScene), location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes); } foreach (var observation in parsed.Observations ?? []) { if (IsLocationEntity(observation.SubjectEntityType)) { AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.SubjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); } if (IsLocationEntity(observation.ObjectEntityType)) { AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.ObjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); } } } private static void AddLocation( Dictionary groups, LocationIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, string? name, string? locationType, string? genericRoomType, string? parentLocationHint, bool presentInScene, bool mentionedOnly, bool isSceneSetting, decimal? confidence, string? notes) { var cleanName = CleanLocationName(name); if (!IsLocationNameCandidate(cleanName)) { return; } var identity = ResolveLocationIdentity(cleanName, existingIndex); if (identity is null || !IsLocationNameCandidate(identity.DisplayName)) { return; } var key = ResolveCandidateKey(groups, identity.Key, identity.DisplayName); if (!groups.TryGetValue(key, out var candidate)) { var match = identity.ExistingMatch; candidate = new LocationCandidate(key, identity.DisplayName, match?.LocationID, match?.LocationName) { Category = Categorise(identity.DisplayName, locationType, genericRoomType), ParentLocationHint = Clean(parentLocationHint) }; groups[key] = candidate; } 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, identity.DisplayName); candidate.ParentLocationHint ??= Clean(parentLocationHint); 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); 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 BuildLocationIndexAsync(int projectId) { var index = new LocationIndex(); foreach (var location in await locations.ListByProjectAsync(projectId)) { index.Add(location.LocationName, location.LocationID, location.LocationName); index.Add(location.LocationPath, location.LocationID, location.LocationName); foreach (var alias in await locations.ListAliasesAsync(location.LocationID)) { index.Add(alias.Alias, location.LocationID, location.LocationName); } } return index; } private async Task TryAddAliasAsync(int locationId, string alias, string primaryName) { var cleanAlias = Clean(alias); if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase)) { return false; } var existingAliases = await locations.ListAliasesAsync(locationId); if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase))) { return false; } await locations.AddAliasAsync(locationId, cleanAlias, null); return true; } private static Scene? ResolveImportedScene( StoryIntelligenceSavedSceneResult sceneResult, IReadOnlyDictionary byId, IReadOnlyDictionary byRange, IReadOnlyDictionary byNumber) { if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch)) { return idMatch; } if (sceneResult.StartParagraph.HasValue && sceneResult.EndParagraph.HasValue && byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch)) { return rangeMatch; } return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null; } private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) { if (string.IsNullOrWhiteSpace(result.ParsedJson)) { return null; } try { var direct = JsonSerializer.Deserialize(result.ParsedJson, JsonOptions); if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion)) { return direct; } using var document = JsonDocument.Parse(result.ParsedJson); if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) { return parsedScene.Deserialize(JsonOptions); } } catch (JsonException) { } return null; } private static int? MatchLocationType(IReadOnlyList types, LocationCandidate candidate) => types.FirstOrDefault(type => candidate.Category.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase) || type.TypeName.Contains(candidate.Category, StringComparison.OrdinalIgnoreCase))?.LocationTypeID; private static int? ResolveParentLocationId(string? parentHint, LocationIndex index) => string.IsNullOrWhiteSpace(parentHint) ? null : index.Find(parentHint)?.LocationID; private static string BuildLocationDescription(LocationCandidate candidate) { var lines = new List(); if (!string.IsNullOrWhiteSpace(candidate.ParentLocationHint)) { lines.Add($"Parent/location hint from Story Intelligence: {candidate.ParentLocationHint}"); } if (!string.IsNullOrWhiteSpace(candidate.Description)) { lines.Add(candidate.Description); } return string.Join(Environment.NewLine, lines); } private static bool IsVisibleLocationCandidate(LocationCandidate candidate) { if (IsMergedLocationPhrase(candidate.DisplayName)) { return false; } if (IsGenericComposite(candidate.DisplayName)) { return false; } if (ContainsThrowawayFragment(candidate.DisplayName)) { return false; } if (IsStoryLocation(candidate.DisplayName)) { return true; } var presentSceneCount = candidate.Appearances .Where(appearance => appearance.PresentInScene) .Select(appearance => appearance.SceneID) .Distinct() .Count(); var settingSceneCount = candidate.Appearances .Where(appearance => appearance.IsSceneSetting) .Select(appearance => appearance.SceneID) .Distinct() .Count(); if (HasUsefulParentHint(candidate.ParentLocationHint)) { return presentSceneCount > 0; } if (IsGenericLocation(candidate.DisplayName)) { return settingSceneCount >= 2 && RepeatableGenericSettings.Contains(Normalise(candidate.DisplayName)); } return IsNamedLocation(candidate.DisplayName); } private static bool IsLocationNameCandidate(string? name) { var clean = CleanLocationName(name); return !string.IsNullOrWhiteSpace(clean) && clean.Length > 1 && clean.Any(char.IsLetter); } private static bool IsGenericLocation(string name) { 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(withoutArticle); } private static bool IsGenericComposite(string name) { var clean = Clean(name); var value = Normalise(name); 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) { var value = Normalise(name); return ThrowawayLocationFragments.Any(fragment => value.Contains(fragment, StringComparison.OrdinalIgnoreCase)); } private static bool IsMergedLocationPhrase(string name) { var clean = StripParenthetical(name); var value = Normalise(clean); if (!value.Contains(" and ", StringComparison.OrdinalIgnoreCase)) { return false; } if (LooksLikeNamedCompound(clean)) { return false; } var parts = value.Split(" and ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); return parts.Length > 1 && parts.All(part => ConjunctionLocationWords.Contains(part) || ContainsConjunctionLocationWord(part)); } 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); return !string.IsNullOrWhiteSpace(clean) && !IsMergedLocationPhrase(clean) && !IsGenericLocation(clean) && !ContainsThrowawayFragment(clean); } private static bool IsStoryLocation(string name) { var clean = StripParenthetical(name); var normalised = Normalise(clean); if (IsGenericLocation(clean) || IsGenericComposite(clean)) { return false; } if (IsBarePossessiveReference(clean)) { return false; } if (AddressPattern.IsMatch(clean) || IsNamedRoad(clean)) { return true; } return clean.Any(char.IsUpper) && (StoryLocationSignals.Any(signal => normalised.Contains(signal, StringComparison.OrdinalIgnoreCase)) || clean.Contains('\'') || clean.Contains('’')); } 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) || withoutParentheses.Contains('\'') || withoutParentheses.Contains('’'); } private static bool IsNamedRoad(string name) { var withoutParentheses = StripParenthetical(name); return !name.Contains('/') && withoutParentheses.Any(char.IsUpper) && NamedRoadPattern.IsMatch(withoutParentheses) && !GenericLocations.Contains(Normalise(name)) && !GenericLocations.Contains(Normalise(withoutParentheses)); } private static string Categorise(string name, string? locationType, string? genericRoomType) { if (AddressPattern.IsMatch(name)) { return "Address"; } if (IsNamedRoad(name)) { return "Road / street"; } if (Normalise(locationType).Contains("generic", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrWhiteSpace(genericRoomType)) { return "Room / interior area"; } if (Normalise(name).Contains("car", StringComparison.OrdinalIgnoreCase)) { return "Vehicle-as-location"; } return IsNamedLocation(name) ? "Named location" : "Ambiguous location"; } private static bool IsLocationEntity(string? entityType) => string.Equals(Clean(entityType), "Location", StringComparison.OrdinalIgnoreCase); private static bool IsPresentPredicate(string? predicate) { var value = Clean(predicate); return value.Contains("isPresentAt", StringComparison.OrdinalIgnoreCase) || value.Contains("travelsTo", StringComparison.OrdinalIgnoreCase) || value.Contains("occursAt", StringComparison.OrdinalIgnoreCase); } private static void AddResolvedName(Dictionary map, string name, int locationId) { var clean = Clean(name); if (!string.IsNullOrWhiteSpace(clean)) { map[clean] = locationId; } } private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? locationName, int? locationId, bool createdOrLinked) { batch.LocationDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); batch.LocationDecisions.Add(new OnboardingStoryIntelligenceLocationDecision { Key = key, Action = action, LocationName = locationName, LocationID = locationId, CreatedOrLinked = createdOrLinked }); } private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string locationName) { var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short)); return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {locationName}" : $"{BuildSceneReference(scene)}: {summary}"; } private static string BuildSceneReference(Scene scene) => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; private static string FirstConfigured(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; private static decimal? Max(decimal? current, decimal? next) => current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next; private static string ConfidenceBand(decimal? confidence) => confidence switch { >= 0.75m => "High", >= 0.45m => "Medium", null => "Unknown", _ => "Low" }; private static string CanonicalLocationKey(string 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 ResolveCandidateKey( IReadOnlyDictionary groups, string canonicalKey, string displayName) { if (groups.ContainsKey(canonicalKey)) { return canonicalKey; } if (!IsGenericLocation(displayName)) { return canonicalKey; } var contextual = groups.Keys .Where(key => key.EndsWith($" {canonicalKey}", StringComparison.OrdinalIgnoreCase)) .ToList(); return contextual.Count == 1 ? contextual[0] : canonicalKey; } private static string StripLowValueContextSuffix(string value) { var clean = Clean(value); 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+and\s+waiting\s+area\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); if (string.IsNullOrWhiteSpace(clean)) { return clean; } var structuralSetting = CleanLocationName(scene.StructuralSetting); var leaf = CleanLocationName(FirstConfigured(genericRoomType, clean)); if (!string.IsNullOrWhiteSpace(structuralSetting) && StructuralSettingCoversLocation(structuralSetting, leaf)) { return structuralSetting; } var povName = Clean(scene.POVCharacterName ?? scene.POVSuggestedName); if (!string.IsNullOrWhiteSpace(povName)) { clean = Regex.Replace(clean, @"\bNarrator's\b", $"{povName}'s", RegexOptions.IgnoreCase); clean = Regex.Replace(clean, @"\bNarrator’s\b", $"{povName}'s", RegexOptions.IgnoreCase); if (clean.StartsWith("my ", StringComparison.OrdinalIgnoreCase)) { return $"{povName}'s {clean[3..].Trim()}"; } } return clean; } private static bool StructuralSettingCoversLocation(string structuralSetting, string locationName) { var settingKey = CanonicalLocationKey(structuralSetting); var locationKey = CanonicalLocationKey(locationName); if (string.IsNullOrWhiteSpace(settingKey) || string.IsNullOrWhiteSpace(locationKey)) { return false; } return settingKey.Equals(locationKey, StringComparison.OrdinalIgnoreCase) || settingKey.EndsWith($" {locationKey}", StringComparison.OrdinalIgnoreCase); } private static string StripParenthetical(string? value) => ParentheticalSuffix.Replace(Clean(value), string.Empty); private static string CleanLocationName(string? 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); private static string Clean(string? value) => StoryIntelligenceEntityTextNormaliser.Clean(value); private static HashSet CreateSet(params string[] values) => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); private sealed class LocationIndex { private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); public void Add(string? name, int locationId, string locationName) { var key = CanonicalLocationKey(name ?? string.Empty); if (!string.IsNullOrWhiteSpace(key)) { byName.TryAdd(key, new LocationMatch(locationId, locationName)); } } public LocationMatch? Find(string name) => byName.TryGetValue(CanonicalLocationKey(name), out var match) ? match : null; } private sealed record LocationMatch(int LocationID, string LocationName); private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName) { public string Key { get; } = key; public string DisplayName { get; set; } = displayName; public int? ExistingLocationID { get; } = existingLocationId; public string? ExistingLocationName { get; } = existingLocationName; public string Category { get; set; } = "Ambiguous location"; public string? ParentLocationHint { get; set; } public HashSet Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); public List Appearances { get; } = []; public decimal? Confidence { get; set; } public string? Description { get; set; } public string? FirstAppearanceNote { get; set; } } private sealed record LocationAppearanceImport( int SceneID, decimal SceneNumber, string SceneTitle, string SceneReference, string ObservedName, bool PresentInScene, bool MentionedOnly, bool IsSceneSetting, string? Notes, decimal? Confidence, bool AlreadyLinked); private sealed record LocationIdentity(string Key, string DisplayName, LocationMatch? ExistingMatch); private sealed record LocationCandidateData( bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList Candidates, IReadOnlySet DecidedKeys); }