Hide exact known location imports from review

This commit is contained in:
Nick Beckley 2026-08-31 19:52:09 +00:00
parent 4579476869
commit e37c761b46
6 changed files with 409 additions and 56 deletions

View File

@ -50,6 +50,8 @@ var tests = new (string Name, Action Test)[]
("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants), ("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants),
("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations), ("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations),
("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets), ("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets),
("Story Intelligence auto resolves exact known locations before review", StoryIntelligenceAutoResolvesExactKnownLocationsBeforeReview),
("Story Intelligence location review uses single identity decision", StoryIntelligenceLocationReviewUsesSingleIdentityDecision),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), ("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants), ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
@ -2710,6 +2712,60 @@ static void LocationReviewUxSupportsLargeCandidateSets()
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching."); Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching.");
} }
static void StoryIntelligenceAutoResolvesExactKnownLocationsBeforeReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
Assert(service.Contains("Task<StoryIntelligenceLocationAutoResolutionResult> ResolveAutomaticMatchesAsync", StringComparison.Ordinal),
"Location import should expose deterministic auto-resolution before persisted review generation.");
Assert(candidateService.Contains("await locationImport.ResolveAutomaticMatchesAsync(batch)", StringComparison.Ordinal),
"Persisted Location review preparation should resolve exact known identities before saving review rows.");
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal),
"Location preferred names and all aliases should feed the identity lookup.");
Assert(service.Contains("Dictionary<string, List<LocationMatch>> byName", StringComparison.Ordinal),
"Known-location lookup should retain all LocationID owners for an alias key.");
Assert(service.Contains("matches.Select(match => match.LocationID).Distinct().Count() == 1", StringComparison.Ordinal),
"Known-location lookup should only return a match when there is exactly one owner.");
Assert(service.Contains("IsAutoResolvableKnownIdentity", StringComparison.Ordinal),
"Exact known locations should be recognised separately from reviewable new or ambiguous locations.");
Assert(service.Contains("existingIndex.Find(candidate.DisplayName)?.LocationID == candidate.ExistingLocationID.Value", StringComparison.Ordinal),
"Preferred-name and alias exact matches should auto-resolve only when the canonical lookup returns the same LocationID.");
Assert(service.Contains("LinkSceneLocationsAsync(candidate, locationId)", StringComparison.Ordinal),
"Auto-resolution should preserve scene/location associations.");
Assert(service.Contains("return null;", StringComparison.Ordinal) && service.Contains("if (linkExisting && !locationId.HasValue)", StringComparison.Ordinal),
"A same-location decision without a canonical or pending target must not create a duplicate Location.");
}
static void StoryIntelligenceLocationReviewUsesSingleIdentityDecision()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
Assert(model.Contains("SameLocationOptions", StringComparison.Ordinal), "Location review should expose canonical LocationID targets.");
Assert(model.Contains("public int? ExistingLocationID { get; set; }", StringComparison.Ordinal), "Same-location decisions should post a canonical LocationID.");
Assert(candidateService.Contains("DecisionCanonicalID = choice.ExistingLocationID", StringComparison.Ordinal), "Persisted review decisions should record canonical LocationID targets.");
Assert(service.Contains("selectedExisting?.LocationID", StringComparison.Ordinal), "Import should resolve same-location targets by canonical LocationID.");
Assert(service.Contains("TryAddAliasAsync(locationId.Value, candidate.DisplayName", StringComparison.Ordinal), "Same-location import should attach the detected name directly to the target LocationID.");
Assert(view.Contains("Same location as", StringComparison.Ordinal), "Review should present one clear identity decision.");
Assert(view.Contains("Same location as existing", StringComparison.Ordinal), "Review should clearly distinguish canonical Location targets.");
Assert(view.Contains("Same location as pending candidate", StringComparison.Ordinal), "Review should preserve pending candidate-to-candidate matching.");
Assert(view.Contains("name=\"Locations[@i].ExistingLocationID\"", StringComparison.Ordinal), "Same-location dropdown should submit LocationID values.");
Assert(view.Contains("Model.LocationReview.SameLocationOptions", StringComparison.Ordinal), "Selector should list one canonical row per LocationID.");
Assert(view.Contains(".OrderBy(target => target.LocationName, NaturalStringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal),
"Pending candidate targets should be sorted in natural alphabetical order.");
Assert(candidateService.Contains(".OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal),
"Canonical Location identity selectors should be sorted in natural alphabetical order by preferred name.");
Assert(File.ReadAllText(Path.Combine(root, "Services/NaturalStringComparer.cs")).Contains("CompareNumberRuns", StringComparison.Ordinal),
"Location selector sorting should compare embedded numbers naturally.");
Assert(!view.Contains("Link existing location", StringComparison.Ordinal), "Review should not expose the old separate link-existing wording.");
Assert(!view.Contains("Alias of another location", StringComparison.Ordinal), "Review should not expose alias-chain wording.");
}
static bool IsMergedLocationPhrase(string name) static bool IsMergedLocationPhrase(string name)
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name); => InvokePrivateLocationFilter("IsMergedLocationPhrase", name);

View File

@ -0,0 +1,80 @@
namespace PlotLine.Services;
public sealed class NaturalStringComparer : IComparer<string?>
{
public static readonly NaturalStringComparer OrdinalIgnoreCase = new();
private NaturalStringComparer()
{
}
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y))
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
var xIndex = 0;
var yIndex = 0;
while (xIndex < x.Length && yIndex < y.Length)
{
if (char.IsDigit(x[xIndex]) && char.IsDigit(y[yIndex]))
{
var numberCompare = CompareNumberRuns(x, ref xIndex, y, ref yIndex);
if (numberCompare != 0)
{
return numberCompare;
}
continue;
}
var charCompare = char.ToUpperInvariant(x[xIndex]).CompareTo(char.ToUpperInvariant(y[yIndex]));
if (charCompare != 0)
{
return charCompare;
}
xIndex++;
yIndex++;
}
return x.Length.CompareTo(y.Length);
}
private static int CompareNumberRuns(string x, ref int xIndex, string y, ref int yIndex)
{
var xStart = xIndex;
var yStart = yIndex;
while (xIndex < x.Length && char.IsDigit(x[xIndex]))
{
xIndex++;
}
while (yIndex < y.Length && char.IsDigit(y[yIndex]))
{
yIndex++;
}
var xRun = x[xStart..xIndex].TrimStart('0');
var yRun = y[yStart..yIndex].TrimStart('0');
if (xRun.Length != yRun.Length)
{
return xRun.Length.CompareTo(yRun.Length);
}
var valueCompare = string.Compare(xRun, yRun, StringComparison.Ordinal);
return valueCompare != 0 ? valueCompare : (xIndex - xStart).CompareTo(yIndex - yStart);
}
}

View File

@ -8,10 +8,13 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceLocationImportService public interface IStoryIntelligenceLocationImportService
{ {
Task<StoryIntelligenceLocationAutoResolutionResult> ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview);
} }
public sealed record StoryIntelligenceLocationAutoResolutionResult(int CandidatesResolved, int SceneLocationsLinked);
public sealed class StoryIntelligenceLocationImportService( public sealed class StoryIntelligenceLocationImportService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
ILocationRepository locations, ILocationRepository locations,
@ -74,6 +77,40 @@ public sealed class StoryIntelligenceLocationImportService(
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 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); 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) public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
@ -91,7 +128,9 @@ public sealed class StoryIntelligenceLocationImportService(
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
var parentOptions = ToParentOptions(await locations.ListByProjectAsync(batch.ProjectID)); var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList();
var parentOptions = ToParentOptions(projectLocations);
var identityOptions = await ToIdentityOptionsAsync(projectLocations);
return new StoryIntelligenceLocationReviewViewModel return new StoryIntelligenceLocationReviewViewModel
{ {
HasCommittedScenes = data.HasCommittedScenes, HasCommittedScenes = data.HasCommittedScenes,
@ -99,6 +138,7 @@ public sealed class StoryIntelligenceLocationImportService(
AlreadyLinkedCount = data.AlreadyLinkedCount, AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
ParentLocationOptions = parentOptions, ParentLocationOptions = parentOptions,
SameLocationOptions = identityOptions,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
{ {
Key = candidate.Key, Key = candidate.Key,
@ -223,12 +263,18 @@ public sealed class StoryIntelligenceLocationImportService(
var requestedName = Clean(choice?.ImportName); var requestedName = Clean(choice?.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.LeafName : requestedName; var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.LeafName : requestedName;
var linkExisting = string.Equals(action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); var linkExisting = string.Equals(action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var selectedExisting = existingIndex.Find(choice?.ExistingLocationID);
var matchedExistingId = linkExisting var matchedExistingId = linkExisting
? candidate.ExistingLocationID ? selectedExisting?.LocationID
?? candidate.ExistingLocationID
?? existingIndex.Find(importName, parentId)?.LocationID ?? existingIndex.Find(importName, parentId)?.LocationID
?? existingIndex.Find(candidate.LeafName, parentId)?.LocationID ?? existingIndex.Find(candidate.LeafName, parentId)?.LocationID
: existingIndex.Find(importName, parentId)?.LocationID; : existingIndex.Find(importName, parentId)?.LocationID;
var locationId = matchedExistingId; var locationId = matchedExistingId;
if (linkExisting && !locationId.HasValue)
{
return null;
}
if (!locationId.HasValue) if (!locationId.HasValue)
{ {
@ -251,6 +297,23 @@ public sealed class StoryIntelligenceLocationImportService(
linkedExisting++; linkedExisting++;
} }
if (linkExisting && locationId.HasValue)
{
var primaryName = existingIndex.Find(locationId.Value)?.LocationName ?? candidate.ExistingLocationName ?? importName;
if (await TryAddAliasAsync(locationId.Value, candidate.DisplayName, primaryName))
{
aliasesAdded++;
}
foreach (var alias in candidate.Aliases)
{
if (await TryAddAliasAsync(locationId.Value, alias, primaryName))
{
aliasesAdded++;
}
}
}
AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value); AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value);
AddResolvedName(resolvedNames, candidate.LeafName, locationId.Value); AddResolvedName(resolvedNames, candidate.LeafName, locationId.Value);
AddResolvedName(resolvedNames, importName, locationId.Value); AddResolvedName(resolvedNames, importName, locationId.Value);
@ -282,7 +345,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue; continue;
} }
if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)) if (IsPendingSameLocationChoice(choice))
{ {
continue; continue;
} }
@ -293,7 +356,7 @@ public sealed class StoryIntelligenceLocationImportService(
foreach (var candidate in data.Candidates) foreach (var candidate in data.Candidates)
{ {
if (!choices.TryGetValue(candidate.Key, out var choice) if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)) || !IsPendingSameLocationChoice(choice))
{ {
continue; continue;
} }
@ -303,7 +366,7 @@ public sealed class StoryIntelligenceLocationImportService(
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase) || string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice) || !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase) || string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase) || IsPendingSameLocationChoice(targetChoice)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate) || !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId)) || !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId))
{ {
@ -321,7 +384,7 @@ public sealed class StoryIntelligenceLocationImportService(
} }
AddResolvedName(resolvedNames, candidate.DisplayName, targetLocationId); AddResolvedName(resolvedNames, candidate.DisplayName, targetLocationId);
AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Alias, candidate.DisplayName, targetLocationId, true); AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.LinkExisting, candidate.DisplayName, targetLocationId, true);
} }
foreach (var candidate in data.Candidates) foreach (var candidate in data.Candidates)
@ -332,7 +395,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue; continue;
} }
var locationId = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase) var locationId = IsPendingSameLocationChoice(choice)
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) ? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
: resolvedCandidateIds.GetValueOrDefault(candidate.Key); : resolvedCandidateIds.GetValueOrDefault(candidate.Key);
if (locationId <= 0) if (locationId <= 0)
@ -340,18 +403,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue; continue;
} }
foreach (var appearance in candidate.Appearances.Where(appearance => appearance.PresentInScene && !appearance.AlreadyLinked)) sceneLocationsUpdated += await LinkSceneLocationsAsync(candidate, locationId);
{
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 = completeReview; batch.LocationStageComplete = completeReview;
@ -920,6 +972,31 @@ public sealed class StoryIntelligenceLocationImportService(
}) })
.ToList(); .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<LocationIndex> BuildLocationIndexAsync(int projectId) private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
{ {
var index = new LocationIndex(); var index = new LocationIndex();
@ -936,6 +1013,34 @@ public sealed class StoryIntelligenceLocationImportService(
return index; return index;
} }
private static bool IsAutoResolvableKnownIdentity(LocationCandidate candidate, LocationIndex existingIndex)
=> candidate.ExistingLocationID.HasValue
&& existingIndex.Find(candidate.DisplayName)?.LocationID == candidate.ExistingLocationID.Value;
private static bool IsPendingSameLocationChoice(StoryIntelligenceLocationImportChoiceForm choice)
=> string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(choice.AliasTargetKey));
private async Task<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) private async Task<bool> TryAddAliasAsync(int locationId, string alias, string primaryName)
{ {
var cleanAlias = Clean(alias); var cleanAlias = Clean(alias);
@ -1559,8 +1664,9 @@ public sealed class StoryIntelligenceLocationImportService(
private sealed class LocationIndex private sealed class LocationIndex
{ {
private readonly Dictionary<string, LocationMatch> byName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, List<LocationMatch>> byName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, LocationMatch> byParentAndName = 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) public void Add(string? name, int locationId, string locationName, int? parentLocationId, string? parentLocationName, string? locationPath)
{ {
@ -1568,8 +1674,9 @@ public sealed class StoryIntelligenceLocationImportService(
if (!string.IsNullOrWhiteSpace(key)) if (!string.IsNullOrWhiteSpace(key))
{ {
var match = new LocationMatch(locationId, locationName, parentLocationId, parentLocationName, locationPath); var match = new LocationMatch(locationId, locationName, parentLocationId, parentLocationName, locationPath);
byName.TryAdd(key, match); byId.TryAdd(locationId, match);
byParentAndName.TryAdd(ParentAwareCandidateKey(parentLocationId?.ToString(), key), match); AddMatch(byName, key, match);
AddMatch(byParentAndName, ParentAwareCandidateKey(parentLocationId?.ToString(), key), match);
} }
} }
@ -1583,12 +1690,37 @@ public sealed class StoryIntelligenceLocationImportService(
if (parentLocationId.HasValue) if (parentLocationId.HasValue)
{ {
return byParentAndName.TryGetValue(ParentAwareCandidateKey(parentLocationId.Value.ToString(), key), out var parentMatch) return FindUnambiguous(byParentAndName, ParentAwareCandidateKey(parentLocationId.Value.ToString(), key));
? parentMatch
: null;
} }
return byName.TryGetValue(key, out var match) ? match : null; 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;
} }
} }

View File

@ -9,6 +9,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceReviewCandidateService public interface IStoryIntelligenceReviewCandidateService
{ {
Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
@ -65,15 +66,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch) public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{ {
await PersistAsync( await PrepareLocationCandidatesAsync(batch);
batch,
StoryIntelligenceReviewModules.Locations,
(await locationImport.BuildReviewAsync(batch)).Candidates,
candidate => candidate.Key,
candidate => candidate.LocationName,
candidate => candidate.Category,
candidate => candidate.ExistingLocationID,
candidate => candidate.EvidenceSummaries.Take(5));
await PersistAsync( await PersistAsync(
batch, batch,
@ -106,6 +99,31 @@ public sealed class StoryIntelligenceReviewCandidateService(
candidate => new[] { candidate.FirstScene, candidate.ExampleScene, candidate.Evidence }.Where(value => !string.IsNullOrWhiteSpace(value))!); candidate => new[] { candidate.FirstScene, candidate.ExampleScene, candidate.Evidence }.Where(value => !string.IsNullOrWhiteSpace(value))!);
} }
public async Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
var automatic = await locationImport.ResolveAutomaticMatchesAsync(batch);
var review = await locationImport.BuildReviewAsync(batch);
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Locations,
review.Candidates,
candidate => candidate.Key,
candidate => candidate.LocationName,
candidate => candidate.Category,
candidate => candidate.ExistingLocationID,
candidate => candidate.EvidenceSummaries.Take(5));
if (automatic.CandidatesResolved > 0)
{
logger.LogInformation(
"Persisted Story Intelligence location review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneLocationsLinked={SceneLocationsLinked}",
batch.BatchID,
batch.ProjectID,
batch.BookID,
automatic.CandidatesResolved,
automatic.SceneLocationsLinked);
}
}
public async Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) public async Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{ {
var page = await LoadPageAsync<StoryIntelligenceCharacterReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize); var page = await LoadPageAsync<StoryIntelligenceCharacterReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize);
@ -159,7 +177,8 @@ public sealed class StoryIntelligenceReviewCandidateService(
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{ {
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize); var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
var parentOptions = (await locations.ListByProjectAsync(batch.ProjectID)) var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList();
var parentOptions = projectLocations
.OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase)
.ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase) .ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
.Select(location => new StoryIntelligenceLocationParentOptionViewModel .Select(location => new StoryIntelligenceLocationParentOptionViewModel
@ -169,6 +188,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
LocationPath = location.LocationPath LocationPath = location.LocationPath
}) })
.ToList(); .ToList();
var sameLocationOptions = await BuildSameLocationOptionsAsync(projectLocations);
return new StoryIntelligenceLocationReviewViewModel return new StoryIntelligenceLocationReviewViewModel
{ {
HasCommittedScenes = true, HasCommittedScenes = true,
@ -180,6 +200,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
TotalCandidateCount = page.TotalCount, TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount, PendingCandidateCount = page.PendingCount,
ParentLocationOptions = parentOptions, ParentLocationOptions = parentOptions,
SameLocationOptions = sameLocationOptions,
Candidates = page.Items Candidates = page.Items
}; };
} }
@ -277,13 +298,39 @@ public sealed class StoryIntelligenceReviewCandidateService(
Module = StoryIntelligenceReviewModules.Locations, Module = StoryIntelligenceReviewModules.Locations,
StableKey = choice.Key, StableKey = choice.Key,
DecisionAction = choice.Action, DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.ExistingLocationID
}); });
} }
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0; return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0;
} }
private async Task<IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel>> BuildSameLocationOptionsAsync(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();
}
public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices) public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices)
{ {
StoryIntelligenceReviewCandidateGeneration? generation = null; StoryIntelligenceReviewCandidateGeneration? generation = null;

View File

@ -454,6 +454,7 @@ public sealed class StoryIntelligenceLocationReviewViewModel
public bool HasPreviousPage => PageNumber > 1; public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount; public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = []; public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel> SameLocationOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = [];
} }
@ -465,6 +466,13 @@ public sealed class StoryIntelligenceLocationParentOptionViewModel
public string DisplayName => string.IsNullOrWhiteSpace(LocationPath) ? LocationName : LocationPath; public string DisplayName => string.IsNullOrWhiteSpace(LocationPath) ? LocationName : LocationPath;
} }
public sealed class StoryIntelligenceLocationIdentityOptionViewModel
{
public int LocationID { get; init; }
public string LocationName { get; init; } = string.Empty;
public string? AliasSummary { get; init; }
}
public sealed class StoryIntelligenceLocationReviewCandidateViewModel public sealed class StoryIntelligenceLocationReviewCandidateViewModel
{ {
public string Key { get; init; } = string.Empty; public string Key { get; init; } = string.Empty;
@ -500,6 +508,7 @@ public sealed class StoryIntelligenceLocationImportChoiceForm
public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew; public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew;
public string? ImportName { get; set; } public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; } public string? AliasTargetKey { get; set; }
public int? ExistingLocationID { get; set; }
public int? ParentLocationID { get; set; } public int? ParentLocationID { get; set; }
public string? ParentSelection { get; set; } public string? ParentSelection { get; set; }
} }

View File

@ -11,7 +11,7 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence</p> <p class="eyebrow">Story Intelligence</p>
<h1 id="story-location-title">Review Locations</h1> <h1 id="story-location-title">Review Locations</h1>
<p>Review place suggestions that need a create, link, alias, or ignore decision.</p> <p>Review place suggestions that need a create, same-location, or ignore decision.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -37,7 +37,7 @@
else else
{ {
<div class="story-review-card-actions"> <div class="story-review-card-actions">
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Link existing matches</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Use exact matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
@if (hiddenCandidateCount > 0) @if (hiddenCandidateCount > 0)
@ -109,12 +109,8 @@
Create new location Create new location
</label> </label>
<label> <label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" data-location-action @(candidate.IsExistingMatch ? string.Empty : "disabled") /> <input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" data-location-action />
Link existing location Same location as
</label>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Alias" data-location-action />
Alias of another location
</label> </label>
<label> <label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Ignore" data-location-action /> <input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Ignore" data-location-action />
@ -156,11 +152,23 @@
</select> </select>
</div> </div>
<div data-location-alias-panel hidden> <div data-location-same-panel hidden>
<label class="form-label" for="location-alias-target-@i">Alias/variant target</label> <label class="form-label" for="location-same-existing-@i">Same location as existing</label>
<select id="location-same-existing-@i" class="form-select" name="Locations[@i].ExistingLocationID" data-location-existing-target>
<option value="">Choose existing location...</option>
@foreach (var target in Model.LocationReview.SameLocationOptions)
{
var isSelected = candidate.ExistingLocationID == target.LocationID;
<option value="@target.LocationID" selected="@isSelected">@target.LocationName@(string.IsNullOrWhiteSpace(target.AliasSummary) ? string.Empty : $" ({target.AliasSummary})")</option>
}
</select>
<label class="form-label mt-3" for="location-alias-target-@i">Same location as pending candidate</label>
<select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target> <select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target>
<option value="">Choose location...</option> <option value="">Choose pending location...</option>
@foreach (var target in locationCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase))) @foreach (var target in locationCandidates
.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase))
.OrderBy(target => target.LocationName, NaturalStringComparer.OrdinalIgnoreCase))
{ {
<option value="@target.Key">@target.LocationName@(target.IsExistingMatch ? $" -> {target.ExistingLocationName}" : string.Empty)</option> <option value="@target.Key">@target.LocationName@(target.IsExistingMatch ? $" -> {target.ExistingLocationName}" : string.Empty)</option>
} }
@ -210,7 +218,9 @@
const actionFor = (card) => card.querySelector("[data-location-action]:checked")?.value || "@StoryIntelligenceLocationImportActions.CreateNew"; const actionFor = (card) => card.querySelector("[data-location-action]:checked")?.value || "@StoryIntelligenceLocationImportActions.CreateNew";
const isCanonicalTarget = (card) => { const isCanonicalTarget = (card) => {
const action = actionFor(card); const action = actionFor(card);
return action !== "@StoryIntelligenceLocationImportActions.Ignore" && action !== "@StoryIntelligenceLocationImportActions.Alias"; const pendingTarget = card.querySelector("[data-location-alias-target]")?.value || "";
return action !== "@StoryIntelligenceLocationImportActions.Ignore"
&& !pendingTarget;
}; };
const updateAliasTargets = () => { const updateAliasTargets = () => {
const allCards = cards(); const allCards = cards();
@ -236,18 +246,18 @@
const updateCard = (card) => { const updateCard = (card) => {
const action = actionFor(card); const action = actionFor(card);
const namePanel = card.querySelector("[data-location-import-name-panel]"); const namePanel = card.querySelector("[data-location-import-name-panel]");
const aliasPanel = card.querySelector("[data-location-alias-panel]"); const samePanel = card.querySelector("[data-location-same-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias"; if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.LinkExisting";
const parentPanel = card.querySelector("[data-location-parent-panel]"); const parentPanel = card.querySelector("[data-location-parent-panel]");
if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias"; if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.LinkExisting";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias"; if (samePanel) samePanel.hidden = action !== "@StoryIntelligenceLocationImportActions.LinkExisting";
updateAliasTargets(); updateAliasTargets();
}; };
document.querySelectorAll("[data-location-bulk]").forEach(button => { document.querySelectorAll("[data-location-bulk]").forEach(button => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const action = button.getAttribute("data-location-bulk"); const action = button.getAttribute("data-location-bulk");
if (action === "link-existing") cards().forEach(card => { if (action === "link-existing") cards().forEach(card => {
if (card.querySelector(`[data-location-action][value="@StoryIntelligenceLocationImportActions.LinkExisting"]:not(:disabled)`)) { if (card.querySelector("[data-location-existing-target]")?.value) {
setAction(card, "@StoryIntelligenceLocationImportActions.LinkExisting"); setAction(card, "@StoryIntelligenceLocationImportActions.LinkExisting");
} }
}); });
@ -258,6 +268,25 @@
form.querySelectorAll("[data-location-action]").forEach(input => { form.querySelectorAll("[data-location-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]"))); input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
}); });
form.querySelectorAll("[data-location-existing-target]").forEach(select => {
select.addEventListener("change", () => {
if (select.value) {
const card = select.closest("[data-location-card]");
const pending = card?.querySelector("[data-location-alias-target]");
if (pending) pending.value = "";
}
});
});
form.querySelectorAll("[data-location-alias-target]").forEach(select => {
select.addEventListener("change", () => {
if (select.value) {
const card = select.closest("[data-location-card]");
const existing = card?.querySelector("[data-location-existing-target]");
if (existing) existing.value = "";
}
cards().forEach(updateCard);
});
});
cards().forEach(updateCard); cards().forEach(updateCard);
})(); })();
</script> </script>