using System.Text.Json; using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceLocationImportService { Task ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch); Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview); } public sealed record StoryIntelligenceLocationAutoResolutionResult(int CandidatesResolved, int SceneLocationsLinked); 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 ParentableGenericLocations = CreateSet( "room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs", "landing", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden", "staff room", "living room", "sitting room"); private static readonly HashSet 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 PossessiveGenericLocation = new(@"^(?[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex GenericAtOwnedLocation = new(@"^(?:the\s+|a\s+|an\s+)?(?bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\s+(?:at|in|inside|within)\s+(?[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)(?:['’]s)?(?:\s+(?:house|home|flat|apartment))?$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex LeadingContextPhrase = new(@"^(?:inside|outside|at|in|within|near|around|beside|by)\s+(?:the\s+|a\s+|an\s+)?", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex 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 ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { return new StoryIntelligenceLocationAutoResolutionResult(0, 0); } var existingIndex = await BuildLocationIndexAsync(batch.ProjectID); var resolvedCandidates = 0; var sceneLocationsLinked = 0; foreach (var candidate in data.Candidates.Where(candidate => IsAutoResolvableKnownIdentity(candidate, existingIndex))) { var locationId = candidate.ExistingLocationID!.Value; sceneLocationsLinked += await LinkSceneLocationsAsync(candidate, locationId); resolvedCandidates++; AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.LinkExisting, candidate.DisplayName, locationId, true); } if (resolvedCandidates > 0) { logger.LogInformation( "Automatically resolved Story Intelligence location candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneLocationsLinked={SceneLocationsLinked}", batch.BatchID, batch.ProjectID, batch.BookID, resolvedCandidates, sceneLocationsLinked); } return new StoryIntelligenceLocationAutoResolutionResult(resolvedCandidates, sceneLocationsLinked); } 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(); var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList(); var parentOptions = ToParentOptions(projectLocations); var identityOptions = await ToIdentityOptionsAsync(projectLocations); return new StoryIntelligenceLocationReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, ParentLocationOptions = parentOptions, SameLocationOptions = identityOptions, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel { Key = candidate.Key, LocationName = candidate.DisplayName, LeafLocationName = candidate.LeafName, ImportName = candidate.LeafName, Category = candidate.Category, AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), Confidence = ConfidenceBand(candidate.Confidence), ParentLocationHint = candidate.ParentLocationHint, ParentIdentityKey = candidate.ParentIdentityKey, ProposedParentLocationID = candidate.ProposedParentLocationID, ProposedParentLocationName = candidate.ProposedParentLocationName, ProposedParentCandidateKey = candidate.ProposedParentCandidateKey, 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, bool completeReview) { 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); var materialising = new HashSet(StringComparer.OrdinalIgnoreCase); async Task ResolveParentIdAsync(LocationCandidate candidate, StoryIntelligenceLocationImportChoiceForm? choice) { var selection = Clean(choice?.ParentSelection); if (string.Equals(selection, "__none__", StringComparison.OrdinalIgnoreCase)) { return null; } if (int.TryParse(selection, out var selectedParentId) && selectedParentId > 0) { return selectedParentId; } if (choice?.ParentLocationID is > 0) { return choice.ParentLocationID; } if (candidate.ProposedParentLocationID.HasValue) { return candidate.ProposedParentLocationID; } if (!string.IsNullOrWhiteSpace(candidate.ProposedParentCandidateKey) && candidateByKey.TryGetValue(candidate.ProposedParentCandidateKey, out var parentCandidate)) { return await MaterialiseLocationCandidateAsync(parentCandidate, choices.GetValueOrDefault(parentCandidate.Key), isDependency: true); } return null; } async Task MaterialiseLocationCandidateAsync(LocationCandidate candidate, StoryIntelligenceLocationImportChoiceForm? choice, bool isDependency) { if (resolvedCandidateIds.TryGetValue(candidate.Key, out var resolvedId)) { return resolvedId; } if (!materialising.Add(candidate.Key)) { return null; } try { var action = choice?.Action ?? StoryIntelligenceLocationImportActions.CreateNew; if (string.Equals(action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase) || string.Equals(action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)) { return null; } var parentId = await ResolveParentIdAsync(candidate, choice); var requestedName = Clean(choice?.ImportName); var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.LeafName : requestedName; var linkExisting = string.Equals(action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); var selectedExisting = existingIndex.Find(choice?.ExistingLocationID); var matchedExistingId = linkExisting ? selectedExisting?.LocationID ?? candidate.ExistingLocationID ?? existingIndex.Find(importName, parentId)?.LocationID ?? existingIndex.Find(candidate.LeafName, parentId)?.LocationID : existingIndex.Find(importName, parentId)?.LocationID; var locationId = matchedExistingId; if (linkExisting && !locationId.HasValue) { return null; } if (!locationId.HasValue) { locationId = await locations.SaveAsync(new LocationItem { ProjectID = batch.ProjectID, ParentLocationID = parentId, LocationName = importName, LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate), Description = BuildLocationDescription(candidate), DetectionPriority = 50 }); if (!isDependency) { created++; } } else if (!isDependency) { linkedExisting++; } if (linkExisting && locationId.HasValue) { var primaryName = existingIndex.Find(locationId.Value)?.LocationName ?? candidate.ExistingLocationName ?? importName; if (await TryAddAliasAsync(locationId.Value, candidate.DisplayName, primaryName)) { aliasesAdded++; } foreach (var alias in candidate.Aliases) { if (await TryAddAliasAsync(locationId.Value, alias, primaryName)) { aliasesAdded++; } } } AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value); AddResolvedName(resolvedNames, candidate.LeafName, locationId.Value); AddResolvedName(resolvedNames, importName, locationId.Value); resolvedCandidateIds[candidate.Key] = locationId.Value; if (!isDependency || choice is not null) { AddDecision(batch, candidate.Key, action, importName, locationId.Value, true); } return locationId; } finally { materialising.Remove(candidate.Key); } } foreach (var candidate in data.Candidates) { 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 (IsPendingSameLocationChoice(choice)) { continue; } await MaterialiseLocationCandidateAsync(candidate, choice, isDependency: false); } foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice) || !IsPendingSameLocationChoice(choice)) { 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) || IsPendingSameLocationChoice(targetChoice) || !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.LinkExisting, 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 = IsPendingSameLocationChoice(choice) ? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) : resolvedCandidateIds.GetValueOrDefault(candidate.Key); if (locationId <= 0) { continue; } sceneLocationsUpdated += await LinkSceneLocationsAsync(candidate, locationId); } batch.LocationStageComplete = completeReview; batch.LastLocationImportResult = new StoryIntelligenceLocationImportBatchResult { LocationsCreated = created, LocationsLinked = linkedExisting, LocationAliasesAdded = aliasesAdded, LocationsIgnored = ignored, SceneLocationsUpdated = sceneLocationsUpdated }; 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); LinkParentCandidates(groups); 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 sceneParentContext = InferSceneParentContext(importedScene, parsed); var structuralSetting = Clean(importedScene.StructuralSetting); if (!string.IsNullOrWhiteSpace(structuralSetting)) { AddLocation(groups, existingIndex, importedScene, parsed, structuralSetting, "named", null, null, null, true, false, true, 0.95m, "Structural setting from Core Import."); } var setting = parsed.Setting; var settingName = ResolveContextualLocationName(FirstConfigured(setting?.LocationName, setting?.GenericRoomType), setting?.GenericRoomType, importedScene); AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, sceneParentContext, true, false, true, setting?.Confidence, "Scene setting"); foreach (var location in parsed.Locations ?? []) { AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(location.Name, location.GenericRoomType, importedScene), location.LocationType, location.GenericRoomType, location.ParentLocationHint, sceneParentContext, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes); } foreach (var observation in parsed.Observations ?? []) { if (IsLocationEntity(observation.SubjectEntityType)) { AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.SubjectName, null, importedScene), null, null, null, sceneParentContext, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); } if (IsLocationEntity(observation.ObjectEntityType)) { AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.ObjectName, null, importedScene), null, null, null, sceneParentContext, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); } } } private static void AddLocation( Dictionary groups, LocationIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, string? name, string? locationType, string? genericRoomType, string? parentLocationHint, LocationParentContext? sceneParentContext, bool presentInScene, bool mentionedOnly, bool isSceneSetting, decimal? confidence, string? notes) { var cleanName = CleanLocationName(name); if (!IsLocationNameCandidate(cleanName)) { return; } var identity = ResolveLocationIdentity(cleanName, null, existingIndex); if (identity is null || !IsLocationNameCandidate(identity.DisplayName)) { return; } var parentContext = ResolveParentContext(cleanName, identity.DisplayName, genericRoomType, parentLocationHint, sceneParentContext, existingIndex); EnsureParentCandidate(groups, existingIndex, importedScene, parsed, parentContext); identity = ResolveLocationIdentity(parentContext.LeafName, parentContext.ProposedParentLocationID, existingIndex); if (identity is null || !IsLocationNameCandidate(identity.DisplayName)) { return; } var key = ParentAwareCandidateKey(parentContext.ParentIdentityKey, identity.Key); if (!groups.TryGetValue(key, out var candidate)) { var match = parentContext.ParentIdentityKey is not null && !parentContext.ProposedParentLocationID.HasValue ? null : identity.ExistingMatch; candidate = new LocationCandidate(key, identity.DisplayName, match?.LocationID, match?.LocationName) { LeafName = identity.DisplayName, LeafIdentityKey = identity.Key, Category = Categorise(identity.DisplayName, locationType, genericRoomType), ParentIdentityKey = parentContext.ParentIdentityKey, ParentLocationHint = parentContext.DisplayHint, ProposedParentLocationID = parentContext.ProposedParentLocationID, ProposedParentLocationName = parentContext.ProposedParentLocationName, ProposedParentCandidateKey = parentContext.ProposedParentCandidateKey }; 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 ??= parentContext.DisplayHint; candidate.ProposedParentLocationID ??= parentContext.ProposedParentLocationID; candidate.ProposedParentLocationName ??= parentContext.ProposedParentLocationName; candidate.ProposedParentCandidateKey ??= parentContext.ProposedParentCandidateKey; 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 static void EnsureParentCandidate( Dictionary groups, LocationIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, LocationParentContext parentContext) { if (parentContext.ProposedParentLocationID.HasValue || string.IsNullOrWhiteSpace(parentContext.ProposedParentLocationName) || !IsStoryLocation(parentContext.ProposedParentLocationName)) { return; } var parentIdentity = ResolveLocationIdentity(parentContext.ProposedParentLocationName, null, existingIndex); if (parentIdentity is null) { return; } var parentKey = ParentAwareCandidateKey(null, parentIdentity.Key); if (!groups.TryGetValue(parentKey, out var parentCandidate)) { parentCandidate = new LocationCandidate(parentKey, parentIdentity.DisplayName, parentIdentity.ExistingMatch?.LocationID, parentIdentity.ExistingMatch?.LocationName) { LeafName = parentIdentity.DisplayName, LeafIdentityKey = parentIdentity.Key, Category = Categorise(parentIdentity.DisplayName, "named", null) }; groups[parentKey] = parentCandidate; } parentCandidate.Confidence = Max(parentCandidate.Confidence, 0.9m); parentCandidate.Description ??= "Parent location inferred from child location context."; parentCandidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, parentIdentity.DisplayName); var appearance = new LocationAppearanceImport( importedScene.SceneID, importedScene.SceneNumber, importedScene.SceneTitle, BuildSceneReference(importedScene), parentIdentity.DisplayName, true, false, true, "Parent inferred from child location context.", 0.9m, importedScene.PrimaryLocationID.HasValue && parentCandidate.ExistingLocationID == importedScene.PrimaryLocationID); if (!parentCandidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID && string.Equals(existing.ObservedName, appearance.ObservedName, StringComparison.OrdinalIgnoreCase))) { parentCandidate.Appearances.Add(appearance); } } private static void LinkParentCandidates(Dictionary groups) { foreach (var candidate in groups.Values) { if (candidate.ProposedParentLocationID.HasValue || string.IsNullOrWhiteSpace(candidate.ParentIdentityKey)) { continue; } var parentKey = ParentAwareCandidateKey(null, candidate.ParentIdentityKey); if (groups.ContainsKey(parentKey)) { candidate.ProposedParentCandidateKey = parentKey; } } } private static LocationParentContext ResolveParentContext( string observedName, string identityName, string? genericRoomType, string? parentLocationHint, LocationParentContext? sceneParentContext, LocationIndex existingIndex) { var leafName = ResolveLeafLocationName(observedName, identityName, genericRoomType); var explicitParent = InferExplicitParent(observedName, leafName); var parentName = explicitParent ?? UsefulParentName(parentLocationHint) ?? (IsParentableGenericLocation(leafName) ? sceneParentContext?.ProposedParentLocationName : null); var displayHint = FirstConfigured(parentName, parentLocationHint); if (string.IsNullOrWhiteSpace(parentName)) { return new LocationParentContext(leafName, null, null, null, null, Clean(displayHint)); } var parentIdentity = ResolveLocationIdentity(parentName, null, existingIndex); if (parentIdentity is null) { return new LocationParentContext(leafName, null, null, null, null, Clean(displayHint)); } return new LocationParentContext( leafName, parentIdentity.Key, parentIdentity.ExistingMatch?.LocationID, parentIdentity.ExistingMatch?.LocationName ?? parentIdentity.DisplayName, null, Clean(displayHint)); } private static LocationParentContext? InferSceneParentContext(Scene scene, SceneIntelligenceScene parsed) { var settingParent = UsefulParentName(parsed.Setting?.ParentLocationHint); if (!string.IsNullOrWhiteSpace(settingParent)) { return new LocationParentContext(settingParent, CanonicalLocationKey(settingParent), null, settingParent, null, settingParent); } var structural = CleanLocationName(scene.StructuralSetting); var structuralParent = ExtractParentFromCompositeSetting(structural) ?? ParentFromOwnedLeaf(structural) ?? (IsUsableParentName(structural) ? structural : null); if (!string.IsNullOrWhiteSpace(structuralParent)) { return new LocationParentContext(structuralParent, CanonicalLocationKey(structuralParent), null, structuralParent, null, structuralParent); } var settingName = CleanLocationName(parsed.Setting?.LocationName); var settingNameParent = ExtractParentFromCompositeSetting(settingName) ?? ParentFromOwnedLeaf(settingName) ?? (IsUsableParentName(settingName) ? settingName : null); if (!string.IsNullOrWhiteSpace(settingNameParent)) { return new LocationParentContext(settingNameParent, CanonicalLocationKey(settingNameParent), null, settingNameParent, null, settingNameParent); } var primary = Clean(scene.PrimaryLocationName ?? scene.PrimaryLocationPath); return IsUsableParentName(primary) ? new LocationParentContext(primary, CanonicalLocationKey(primary), null, primary, null, primary) : null; } private static string ResolveLeafLocationName(string observedName, string identityName, string? genericRoomType) { var explicitLeaf = ExplicitLeaf(observedName); if (!string.IsNullOrWhiteSpace(explicitLeaf)) { return explicitLeaf; } var genericLeaf = CleanLocationName(genericRoomType); if (IsParentableGenericLocation(genericLeaf)) { return genericLeaf; } if (IsParentableGenericLocation(identityName)) { return identityName; } return identityName; } private static string? ExplicitLeaf(string observedName) { var clean = CleanLocationName(observedName); var possessive = PossessiveGenericLocation.Match(clean); if (possessive.Success) { return CleanLocationName(possessive.Groups["leaf"].Value); } var genericAt = GenericAtOwnedLocation.Match(clean); return genericAt.Success ? CleanLocationName(genericAt.Groups["leaf"].Value) : null; } private static string? InferExplicitParent(string observedName, string leafName) { var clean = CleanLocationName(observedName); var possessive = PossessiveGenericLocation.Match(clean); if (possessive.Success) { var owner = CleanLocationName(possessive.Groups["owner"].Value); return IsCleanOwnerName(owner) ? ParentNameFromOwner(owner) : null; } var genericAt = GenericAtOwnedLocation.Match(clean); if (genericAt.Success) { var owner = CleanLocationName(genericAt.Groups["owner"].Value); return IsCleanOwnerName(owner) ? ParentNameFromOwner(owner) : null; } return null; } private static string? ParentFromOwnedLeaf(string value) { var clean = CleanLocationName(value); var matches = PossessiveGenericLocation.Matches(StripLeadingContextPhrases(clean)) .Cast() .ToList(); if (matches.Count == 0) { matches = Regex.Matches(clean, @"(?[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\b", RegexOptions.IgnoreCase) .Cast() .ToList(); } foreach (var match in matches.AsEnumerable().Reverse()) { var owner = CleanLocationName(match.Groups["owner"].Value); if (!IsCleanOwnerName(owner)) { continue; } return ParentNameFromOwner(owner); } return null; } private static string? ParentNameFromOwner(string owner) { var clean = CleanLocationName(owner); if (string.IsNullOrWhiteSpace(clean)) { return null; } var normalised = Normalise(clean); if (normalised.EndsWith(" house", StringComparison.OrdinalIgnoreCase) || normalised.EndsWith(" home", StringComparison.OrdinalIgnoreCase) || normalised.EndsWith(" flat", StringComparison.OrdinalIgnoreCase) || normalised.EndsWith(" apartment", StringComparison.OrdinalIgnoreCase)) { return clean; } return $"{clean}'s home"; } private static string? ExtractParentFromCompositeSetting(string? value) { var clean = CleanLocationName(value); if (string.IsNullOrWhiteSpace(clean)) { return null; } var ownedContainer = Regex.Match(clean, @"(?[\p{L}\d][\p{L}\d\s.'’-]{1,80}['’]s\s+(?:house|home|flat|apartment))\s+(?:and|with)\s+(?:bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing)\b", RegexOptions.IgnoreCase); if (ownedContainer.Success) { var parent = CleanLocationName(ownedContainer.Groups["parent"].Value); if (IsUsableParentName(parent)) { return parent; } } var parts = Regex.Split(clean, @"\s*(?:--|-|—|;|,|\bthen\b|\bto\b)\s*", RegexOptions.IgnoreCase) .Where(part => !string.IsNullOrWhiteSpace(part)) .Select(CleanLocationName) .ToList(); foreach (var part in parts) { var ownedParent = ParentFromOwnedLeaf(part); if (IsUsableParentName(ownedParent)) { return ownedParent; } } foreach (var part in parts) { if (IsUsableParentName(part)) { return part; } } return null; } private static string? UsefulParentName(string? parentHint) { var clean = CleanLocationName(parentHint); if (Regex.IsMatch(clean, @"(?:--|-|—|;|,|\bthen\b|\bto\b)", RegexOptions.IgnoreCase)) { return ExtractParentFromCompositeSetting(clean); } return IsUsableParentName(clean) ? clean : null; } private static bool IsUsableParentName(string? value) { var clean = CleanLocationName(value); return !string.IsNullOrWhiteSpace(clean) && IsStoryLocation(clean) && !IsParentableGenericLocation(clean) && !ContainsParentableGenericLocationWord(clean) && !IsGenericComposite(clean) && !Regex.IsMatch(clean, @"\b(?:and|or|to|from|between|through)\b", RegexOptions.IgnoreCase) && !Regex.IsMatch(clean, @"\b(?:road|lane|street|avenue|drive|way|close|court|terrace|square)\b$", RegexOptions.IgnoreCase) && !ContainsThrowawayFragment(clean); } private static bool IsCleanOwnerName(string? value) { var clean = CleanLocationName(value); return !string.IsNullOrWhiteSpace(clean) && !Regex.IsMatch(clean, @"\b(?:and|or|to|from|between|through|beneath|outside|inside)\b", RegexOptions.IgnoreCase); } private static bool ContainsParentableGenericLocationWord(string value) { var normalised = Normalise(value); return ParentableGenericLocations.Any(location => normalised.Equals(location, StringComparison.OrdinalIgnoreCase) || normalised.Contains($" {location}", StringComparison.OrdinalIgnoreCase) || normalised.Contains($"{location} ", StringComparison.OrdinalIgnoreCase)); } private static bool IsParentableGenericLocation(string? name) { var clean = Normalise(name); return ParentableGenericLocations.Contains(clean) || ParentableGenericLocations.Contains(Normalise(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty))); } private static string ParentAwareCandidateKey(string? parentIdentityKey, string leafIdentityKey) => $"{(string.IsNullOrWhiteSpace(parentIdentityKey) ? "unknown" : parentIdentityKey)}|{leafIdentityKey}"; private static IReadOnlyList ToParentOptions(IEnumerable locations) => locations .OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase) .ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase) .Select(location => new StoryIntelligenceLocationParentOptionViewModel { LocationID = location.LocationID, LocationName = location.LocationName, LocationPath = location.LocationPath }) .ToList(); private async Task> ToIdentityOptionsAsync(IReadOnlyList projectLocations) { var options = new List(); foreach (var location in projectLocations) { var aliasNames = (await locations.ListAliasesAsync(location.LocationID)) .Select(alias => alias.Alias) .Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase) .ToList(); options.Add(new StoryIntelligenceLocationIdentityOptionViewModel { LocationID = location.LocationID, LocationName = location.LocationName, AliasSummary = string.Join(", ", aliasNames.Take(4)) }); } return options .OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase) .ThenBy(location => location.LocationID) .ToList(); } 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, location.ParentLocationID, location.ParentLocationName, location.LocationPath); index.Add(location.LocationPath, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath); foreach (var alias in await locations.ListAliasesAsync(location.LocationID)) { index.Add(alias.Alias, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath); } } return index; } private static bool IsAutoResolvableKnownIdentity(LocationCandidate candidate, LocationIndex existingIndex) => candidate.ExistingLocationID.HasValue && existingIndex.Find(candidate.DisplayName)?.LocationID == candidate.ExistingLocationID.Value; private static bool IsPendingSameLocationChoice(StoryIntelligenceLocationImportChoiceForm choice) => string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase) || (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(choice.AliasTargetKey)); private async Task LinkSceneLocationsAsync(LocationCandidate candidate, int locationId) { var updated = 0; 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); updated++; } return updated; } 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, int? parentLocationId, LocationIndex existingIndex) { var clean = CleanLocationName(name); if (string.IsNullOrWhiteSpace(clean)) { return null; } var existingMatch = existingIndex.Find(clean, parentLocationId); if (existingMatch is not null) { return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch); } var identityName = NormaliseLocationIdentityName(clean); existingMatch = existingIndex.Find(identityName, parentLocationId); 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+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); private readonly Dictionary> byParentAndName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary byId = new(); public void Add(string? name, int locationId, string locationName, int? parentLocationId, string? parentLocationName, string? locationPath) { var key = CanonicalLocationKey(name ?? string.Empty); if (!string.IsNullOrWhiteSpace(key)) { var match = new LocationMatch(locationId, locationName, parentLocationId, parentLocationName, locationPath); byId.TryAdd(locationId, match); AddMatch(byName, key, match); AddMatch(byParentAndName, ParentAwareCandidateKey(parentLocationId?.ToString(), key), match); } } public LocationMatch? Find(string name, int? parentLocationId = null) { var key = CanonicalLocationKey(name); if (string.IsNullOrWhiteSpace(key)) { return null; } if (parentLocationId.HasValue) { return FindUnambiguous(byParentAndName, ParentAwareCandidateKey(parentLocationId.Value.ToString(), key)); } return FindUnambiguous(byName, key); } public LocationMatch? Find(int? locationId) => locationId.HasValue && byId.TryGetValue(locationId.Value, out var match) ? match : null; private static void AddMatch(Dictionary> index, string key, LocationMatch match) { if (!index.TryGetValue(key, out var matches)) { matches = []; index[key] = matches; } if (matches.All(existing => existing.LocationID != match.LocationID)) { matches.Add(match); } } private static LocationMatch? FindUnambiguous(Dictionary> index, string key) { if (!index.TryGetValue(key, out var matches)) { return null; } return matches.Select(match => match.LocationID).Distinct().Count() == 1 ? matches[0] : null; } } private sealed record LocationMatch(int LocationID, string LocationName, int? ParentLocationID, string? ParentLocationName, string? LocationPath); private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName) { public string Key { get; } = key; public string DisplayName { get; set; } = displayName; public string LeafName { get; set; } = displayName; public string LeafIdentityKey { get; set; } = key; public int? ExistingLocationID { get; } = existingLocationId; public string? ExistingLocationName { get; } = existingLocationName; public string Category { get; set; } = "Ambiguous location"; public string? ParentIdentityKey { get; set; } public string? ParentLocationHint { get; set; } public int? ProposedParentLocationID { get; set; } public string? ProposedParentLocationName { get; set; } public string? ProposedParentCandidateKey { 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 LocationParentContext( string LeafName, string? ParentIdentityKey, int? ProposedParentLocationID, string? ProposedParentLocationName, string? ProposedParentCandidateKey, string? DisplayHint); private sealed record LocationCandidateData( bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList Candidates, IReadOnlySet DecidedKeys); }