1927 lines
82 KiB
C#
1927 lines
82 KiB
C#
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using PlotLine.Data;
|
||
using PlotLine.Models;
|
||
using PlotLine.ViewModels;
|
||
|
||
namespace PlotLine.Services;
|
||
|
||
public interface IStoryIntelligenceLocationImportService
|
||
{
|
||
Task<StoryIntelligenceLocationAutoResolutionResult> ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch);
|
||
Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||
Task<StoryIntelligenceImportCommitResult> 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<StoryIntelligenceLocationImportService> logger) : IStoryIntelligenceLocationImportService
|
||
{
|
||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||
{
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
PropertyNameCaseInsensitive = true
|
||
};
|
||
|
||
private static readonly HashSet<string> 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<string> ThrowawayLocationFragments = CreateSet(
|
||
"door", "porch", "exterior", "tree", "pavement");
|
||
|
||
private static readonly HashSet<string> 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<string> 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<string> 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<string> SingularisableLocationWords = CreateSet(
|
||
"garage", "road", "street", "room", "house", "flat", "bedroom", "kitchen", "garden", "office");
|
||
|
||
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
|
||
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
|
||
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "inn", "theatre",
|
||
"cinema", "library", "museum", "club", "restaurant", "bar");
|
||
|
||
private static readonly HashSet<string> ConjunctionLocationWords = CreateSet(
|
||
"shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden",
|
||
"rear garden", "front", "rear", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street",
|
||
"car park", "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(@"^(?<owner>[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?<leaf>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+)?(?<leaf>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+(?<owner>[\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<StoryIntelligenceLocationAutoResolutionResult> 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<StoryIntelligenceLocationReviewViewModel> 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);
|
||
var targetOptions = await ToTargetOptionsAsync(projectLocations, visibleCandidates);
|
||
var existingIndex = await BuildLocationIndexAsync(batch.ProjectID);
|
||
return new StoryIntelligenceLocationReviewViewModel
|
||
{
|
||
HasCommittedScenes = data.HasCommittedScenes,
|
||
CanImport = visibleCandidates.Count > 0,
|
||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||
ParentLocationOptions = parentOptions,
|
||
SameLocationOptions = identityOptions,
|
||
LocationTargetOptions = targetOptions,
|
||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
|
||
{
|
||
Key = candidate.Key,
|
||
LocationName = candidate.DisplayName,
|
||
LeafLocationName = candidate.LeafName,
|
||
ImportName = candidate.LeafName,
|
||
Category = candidate.Category,
|
||
Significance = LocationSignificanceService.Assess(candidate.Category, candidate.SignificanceEvidence),
|
||
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,
|
||
ExistingParentLocationID = candidate.ExistingParentLocationID,
|
||
ExistingParentLocationName = candidate.ExistingParentLocationName,
|
||
HasParentReviewIssue = HasParentReviewIssue(candidate, existingIndex),
|
||
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<StoryIntelligenceImportCommitResult> 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<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
|
||
var materialising = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
async Task<int?> ResolveParentIdAsync(LocationCandidate candidate, StoryIntelligenceLocationImportChoiceForm? choice)
|
||
{
|
||
var selection = Clean(choice?.ParentSelection);
|
||
if (string.Equals(selection, "__none__", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (selection.StartsWith("existing:", StringComparison.OrdinalIgnoreCase)
|
||
&& int.TryParse(selection["existing:".Length..], out var existingParentId)
|
||
&& existingParentId > 0)
|
||
{
|
||
return existingParentId;
|
||
}
|
||
|
||
if (selection.StartsWith("candidate:", StringComparison.OrdinalIgnoreCase)
|
||
&& candidateByKey.TryGetValue(selection["candidate:".Length..], out var selectedParentCandidate))
|
||
{
|
||
return await MaterialiseLocationCandidateAsync(selectedParentCandidate, choices.GetValueOrDefault(selectedParentCandidate.Key), isDependency: true);
|
||
}
|
||
|
||
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<int?> 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 (candidate.ExistingLocationID.HasValue
|
||
&& string.Equals(action, StoryIntelligenceLocationImportActions.CreateNew, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
// Identity beats hierarchy: known Locations must not be duplicated because parent context changed.
|
||
action = StoryIntelligenceLocationImportActions.LinkExisting;
|
||
if (choice is not null)
|
||
{
|
||
choice.Action = action;
|
||
}
|
||
}
|
||
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++;
|
||
}
|
||
}
|
||
|
||
await UpdateExistingParentAsync(locationId.Value, parentId);
|
||
}
|
||
|
||
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."
|
||
};
|
||
}
|
||
|
||
public async Task<IReadOnlyDictionary<string, LocationSignificanceAssessment>> BuildSignificanceAsync(OnboardingStoryIntelligenceBatch batch)
|
||
{
|
||
var data = await BuildCandidateDataAsync(batch, includeLinked: true);
|
||
return data.Candidates.ToDictionary(x => x.Key, x => LocationSignificanceService.Assess(x.Category, x.SignificanceEvidence), StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private async Task<LocationCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch, bool includeLinked = false)
|
||
{
|
||
var existingIndex = await BuildLocationIndexAsync(batch.ProjectID);
|
||
var groups = new Dictionary<string, LocationCandidate>(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++;
|
||
}
|
||
|
||
importedScene.BookID = batch.BookID;
|
||
AddSceneLocations(groups, existingIndex, importedScene, parsed);
|
||
}
|
||
}
|
||
|
||
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
LinkParentCandidates(groups);
|
||
// A house hosts events in its rooms. Roll up actual child settings, never unrelated mentions.
|
||
var directEvidence = groups.Values.ToDictionary(x => x.Key, x => x.SignificanceEvidence.ToList(), StringComparer.OrdinalIgnoreCase);
|
||
foreach (var child in groups.Values)
|
||
{
|
||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { child.Key };
|
||
var parentKey = child.ProposedParentCandidateKey;
|
||
while (parentKey is not null && groups.TryGetValue(parentKey, out var parent) && visited.Add(parentKey))
|
||
{
|
||
parent.SignificanceEvidence.AddRange(directEvidence[child.Key].Where(x => x.IsSetting)
|
||
.Select(x => x with { Reasons = new[] { $"Within child setting: {child.DisplayName}" }.Concat(x.Reasons).ToList() }));
|
||
parentKey = parent.ProposedParentCandidateKey;
|
||
}
|
||
}
|
||
var candidates = groups.Values
|
||
.Where(candidate => includeLinked || candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
|
||
.Where(candidate => IsVisibleLocationCandidate(candidate))
|
||
.OrderByDescending(candidate => LocationSignificanceService.Assess(candidate.Category, candidate.SignificanceEvidence).Score)
|
||
.ThenBy(candidate => candidate.DisplayName)
|
||
.ToList();
|
||
|
||
return new LocationCandidateData(hasCommittedScenes, alreadyLinked, candidates, decidedKeys);
|
||
}
|
||
|
||
private static void AddSceneLocations(
|
||
Dictionary<string, LocationCandidate> 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<string, LocationCandidate> 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 initialIdentity = ResolveLocationIdentity(cleanName, null, existingIndex);
|
||
var identity = initialIdentity;
|
||
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 = identity.ExistingMatch is not null
|
||
? 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 = 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,
|
||
ExistingParentLocationID = match?.ParentLocationID,
|
||
ExistingParentLocationName = match?.ParentLocationName
|
||
};
|
||
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.SignificanceEvidence.Add(LocationSignificanceService.Observe(importedScene, parsed, BuildSceneReference(importedScene), isSceneSetting, presentInScene, mentionedOnly, notes));
|
||
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<string, LocationCandidate> 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.SignificanceEvidence.Add(LocationSignificanceService.Observe(importedScene, parsed, BuildSceneReference(importedScene), false, true, false, null));
|
||
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<string, LocationCandidate> 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<Match>()
|
||
.ToList();
|
||
if (matches.Count == 0)
|
||
{
|
||
matches = Regex.Matches(clean, @"(?<owner>[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?<leaf>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<Match>()
|
||
.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, @"(?<parent>[\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<StoryIntelligenceLocationParentOptionViewModel> ToParentOptions(IEnumerable<LocationItem> 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<IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel>> ToIdentityOptionsAsync(IReadOnlyList<LocationItem> projectLocations)
|
||
{
|
||
var options = new List<StoryIntelligenceLocationIdentityOptionViewModel>();
|
||
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<IReadOnlyList<StoryIntelligenceLocationTargetOptionViewModel>> ToTargetOptionsAsync(
|
||
IReadOnlyList<LocationItem> projectLocations,
|
||
IReadOnlyList<LocationCandidate> pendingCandidates)
|
||
{
|
||
var canonicalKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var options = new List<StoryIntelligenceLocationTargetOptionViewModel>();
|
||
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();
|
||
canonicalKeys.Add(CanonicalLocationKey(location.LocationName));
|
||
options.Add(new StoryIntelligenceLocationTargetOptionViewModel
|
||
{
|
||
Value = $"existing:{location.LocationID}",
|
||
LocationID = location.LocationID,
|
||
DisplayName = location.LocationName,
|
||
GroupName = "Existing locations",
|
||
AliasSummary = string.Join(", ", aliasNames.Take(4))
|
||
});
|
||
}
|
||
|
||
foreach (var candidate in pendingCandidates.Where(candidate => !candidate.ExistingLocationID.HasValue))
|
||
{
|
||
var key = CanonicalLocationKey(candidate.DisplayName);
|
||
if (string.IsNullOrWhiteSpace(key) || canonicalKeys.Contains(key))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
options.Add(new StoryIntelligenceLocationTargetOptionViewModel
|
||
{
|
||
Value = $"candidate:{candidate.Key}",
|
||
CandidateKey = candidate.Key,
|
||
DisplayName = candidate.DisplayName,
|
||
GroupName = "Pending suggestions"
|
||
});
|
||
}
|
||
|
||
return options
|
||
.OrderBy(option => option.GroupName, StringComparer.Ordinal)
|
||
.ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase)
|
||
.ThenBy(option => option.Value, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
}
|
||
|
||
private async Task<LocationIndex> 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
|
||
&& !HasParentReviewIssue(candidate, existingIndex);
|
||
|
||
private static bool HasParentReviewIssue(LocationCandidate candidate, LocationIndex existingIndex)
|
||
{
|
||
if (!candidate.ExistingLocationID.HasValue)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var existing = existingIndex.Find(candidate.ExistingLocationID);
|
||
if (existing is null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (candidate.ProposedParentLocationID.HasValue)
|
||
{
|
||
return existing.ParentLocationID != candidate.ProposedParentLocationID;
|
||
}
|
||
|
||
return !string.IsNullOrWhiteSpace(candidate.ProposedParentCandidateKey) && existing.ParentLocationID is null;
|
||
}
|
||
|
||
private async Task UpdateExistingParentAsync(int locationId, int? parentId)
|
||
{
|
||
if (!parentId.HasValue || parentId.Value == locationId)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var location = await locations.GetAsync(locationId);
|
||
if (location is null || location.ParentLocationID == parentId)
|
||
{
|
||
return;
|
||
}
|
||
|
||
location.ParentLocationID = parentId;
|
||
await locations.SaveAsync(location);
|
||
}
|
||
|
||
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<int> 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<bool> 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<int, Scene> byId,
|
||
IReadOnlyDictionary<string, Scene> byRange,
|
||
IReadOnlyDictionary<int, Scene> 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<SceneIntelligenceScene>(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<SceneIntelligenceScene>(JsonOptions);
|
||
}
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static int? MatchLocationType(IReadOnlyList<LocationType> 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<string>();
|
||
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<string, int> 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<string> CreateSet(params string[] values)
|
||
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
|
||
private sealed class LocationIndex
|
||
{
|
||
private readonly Dictionary<string, List<LocationMatch>> byName = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly Dictionary<string, List<LocationMatch>> byParentAndName = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly Dictionary<int, LocationMatch> 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<string, List<LocationMatch>> 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<string, List<LocationMatch>> 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 int? ExistingParentLocationID { get; set; }
|
||
public string? ExistingParentLocationName { get; set; }
|
||
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<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||
public List<LocationAppearanceImport> Appearances { get; } = [];
|
||
public List<LocationSignificanceEvidence> SignificanceEvidence { 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<LocationCandidate> Candidates,
|
||
IReadOnlySet<string> DecidedKeys);
|
||
}
|