PlotDirector/PlotLine/Services/StoryIntelligenceLocationImportService.cs

837 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceLocationImportService
{
Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form);
}
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");
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> RepeatableGenericSettings = CreateSet(
"kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "staff room");
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "doweries");
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");
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);
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
{
Key = candidate.Key,
LocationName = candidate.DisplayName,
ImportName = candidate.DisplayName,
Category = candidate.Category,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
ParentLocationHint = candidate.ParentLocationHint,
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExampleContext = candidate.Description,
ExistingLocationID = candidate.ExistingLocationID,
ExistingLocationName = candidate.ExistingLocationName
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing 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);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Ignore, candidate.DisplayName, null, false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var linkExisting = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = linkExisting
? candidate.ExistingLocationID
?? existingIndex.Find(importName)?.LocationID
?? existingIndex.Find(candidate.DisplayName)?.LocationID
: null;
var locationId = matchedExistingId;
if (!locationId.HasValue)
{
var parentId = ResolveParentLocationId(candidate.ParentLocationHint, existingIndex);
locationId = await locations.SaveAsync(new LocationItem
{
ProjectID = batch.ProjectID,
ParentLocationID = parentId,
LocationName = importName,
LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate),
Description = BuildLocationDescription(candidate),
DetectionPriority = 50
});
created++;
}
else
{
linkedExisting++;
}
AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value);
AddResolvedName(resolvedNames, importName, locationId.Value);
resolvedCandidateIds[candidate.Key] = locationId.Value;
AddDecision(batch, candidate.Key, choice.Action, importName, locationId.Value, true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var targetKey = Clean(choice.AliasTargetKey);
if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId))
{
logger.LogWarning(
"Story Intelligence location alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
var targetName = targetCandidate.ExistingLocationName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
if (await TryAddAliasAsync(targetLocationId, candidate.DisplayName, targetName))
{
aliasesAdded++;
}
AddResolvedName(resolvedNames, candidate.DisplayName, targetLocationId);
AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Alias, candidate.DisplayName, targetLocationId, true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var locationId = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
: resolvedCandidateIds.GetValueOrDefault(candidate.Key);
if (locationId <= 0)
{
continue;
}
foreach (var appearance in candidate.Appearances.Where(appearance => appearance.PresentInScene && !appearance.AlreadyLinked))
{
var scene = await scenes.GetAsync(appearance.SceneID);
if (scene is null || scene.PrimaryLocationID.HasValue)
{
continue;
}
scene.PrimaryLocationID = locationId;
await scenes.SaveAsync(scene);
sceneLocationsUpdated++;
}
}
batch.LocationStageComplete = true;
batch.LastLocationImportResult = new StoryIntelligenceLocationImportBatchResult
{
LocationsCreated = created,
LocationsLinked = linkedExisting,
LocationAliasesAdded = aliasesAdded,
LocationsIgnored = ignored,
SceneLocationsUpdated = sceneLocationsUpdated
};
await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}",
batch.BatchID,
created,
linkedExisting,
aliasesAdded,
ignored,
sceneLocationsUpdated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = sceneLocationsUpdated,
Message = $"Locations imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneLocationsUpdated:N0} scene location(s) updated."
};
}
private async Task<LocationCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
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 commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
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);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
if (importedScene.PrimaryLocationID.HasValue)
{
alreadyLinked++;
}
AddSceneLocations(groups, existingIndex, importedScene, parsed);
}
}
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(candidate => IsVisibleLocationCandidate(candidate))
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName)
.ToList();
return new LocationCandidateData(hasCommittedScenes, alreadyLinked, candidates, decidedKeys);
}
private static void AddSceneLocations(
Dictionary<string, LocationCandidate> groups,
LocationIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed)
{
var setting = parsed.Setting;
var settingName = FirstConfigured(setting?.LocationName, setting?.GenericRoomType);
AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting");
foreach (var location in parsed.Locations ?? [])
{
AddLocation(groups, existingIndex, importedScene, parsed, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes);
}
foreach (var observation in parsed.Observations ?? [])
{
if (IsLocationEntity(observation.SubjectEntityType))
{
AddLocation(groups, existingIndex, importedScene, parsed, observation.SubjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
}
if (IsLocationEntity(observation.ObjectEntityType))
{
AddLocation(groups, existingIndex, importedScene, parsed, observation.ObjectName, null, null, null, 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,
bool presentInScene,
bool mentionedOnly,
bool isSceneSetting,
decimal? confidence,
string? notes)
{
var cleanName = CleanLocationName(name);
if (!IsLocationNameCandidate(cleanName))
{
return;
}
var key = ResolveCandidateKey(groups, existingIndex, cleanName);
if (!groups.TryGetValue(key, out var candidate))
{
var match = existingIndex.Find(cleanName);
candidate = new LocationCandidate(key, cleanName, match?.LocationID, match?.LocationName)
{
Category = Categorise(cleanName, locationType, genericRoomType),
ParentLocationHint = Clean(parentLocationHint)
};
groups[key] = candidate;
}
else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
{
candidate.Aliases.Add(cleanName);
}
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.Description ??= Clean(notes);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName);
candidate.ParentLocationHint ??= Clean(parentLocationHint);
candidate.Appearances.Add(new LocationAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
presentInScene && !mentionedOnly,
mentionedOnly,
isSceneSetting,
notes,
confidence,
importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID));
}
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);
index.Add(location.LocationPath, location.LocationID, location.LocationName);
foreach (var alias in await locations.ListAliasesAsync(location.LocationID))
{
index.Add(alias.Alias, location.LocationID, location.LocationName);
}
}
return index;
}
private async Task<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<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
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));
return GenericLocations.Contains(normalised)
|| GenericLocations.Contains(withoutParentheses)
|| InternalLocationWords.Contains(normalised)
|| InternalLocationWords.Contains(withoutParentheses);
}
private static bool IsGenericComposite(string name)
{
var value = Normalise(name);
if (!value.Contains('/') && !value.Contains(" / "))
{
return false;
}
return true;
}
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 value = Normalise(StripParenthetical(name));
if (!value.Contains(" and ", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (value is "rose and crown")
{
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 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 (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);
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 string ResolveCandidateKey(
IReadOnlyDictionary<string, LocationCandidate> groups,
LocationIndex existingIndex,
string name)
{
var existingMatch = existingIndex.Find(name);
if (existingMatch is not null)
{
var existingKey = CanonicalLocationKey(existingMatch.LocationName);
if (groups.ContainsKey(existingKey))
{
return existingKey;
}
return existingKey;
}
var canonical = CanonicalLocationKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical;
}
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(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {locationName}"
: $"Scene {scene.SceneNumber:g}: {summary}";
}
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 = StripParenthetical(CleanLocationName(name));
if (clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
clean = clean[4..];
}
return Normalise(clean);
}
private static string StripParenthetical(string? value)
=> Regex.Replace(Clean(value), @"\s*\([^)]*\)\s*$", string.Empty);
private static string CleanLocationName(string? value)
{
var clean = Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
if (clean.EndsWith("'s", StringComparison.OrdinalIgnoreCase) || clean.EndsWith("s", StringComparison.OrdinalIgnoreCase))
{
return clean;
}
return clean;
}
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private sealed class LocationIndex
{
private readonly Dictionary<string, LocationMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int locationId, string locationName)
{
var key = CanonicalLocationKey(name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new LocationMatch(locationId, locationName));
}
}
public LocationMatch? Find(string name)
=> byName.TryGetValue(CanonicalLocationKey(name), out var match) ? match : null;
}
private sealed record LocationMatch(int LocationID, string LocationName);
private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName)
{
public string Key { get; } = key;
public string DisplayName { get; } = displayName;
public int? ExistingLocationID { get; } = existingLocationId;
public string? ExistingLocationName { get; } = existingLocationName;
public string Category { get; set; } = "Ambiguous location";
public string? ParentLocationHint { get; set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<LocationAppearanceImport> 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,
bool PresentInScene,
bool MentionedOnly,
bool IsSceneSetting,
string? Notes,
decimal? Confidence,
bool AlreadyLinked);
private sealed record LocationCandidateData(
bool HasCommittedScenes,
int AlreadyLinkedCount,
IReadOnlyList<LocationCandidate> Candidates,
IReadOnlySet<string> DecidedKeys);
}