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 identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations),
("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 preserves story assets", AssetFilteringPreservesStoryAssets),
("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.");
}
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)
=> 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
{
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,
@ -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 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);
@ -91,7 +128,9 @@ public sealed class StoryIntelligenceLocationImportService(
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 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
{
HasCommittedScenes = data.HasCommittedScenes,
@ -99,6 +138,7 @@ public sealed class StoryIntelligenceLocationImportService(
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
ParentLocationOptions = parentOptions,
SameLocationOptions = identityOptions,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
{
Key = candidate.Key,
@ -223,12 +263,18 @@ public sealed class StoryIntelligenceLocationImportService(
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
? candidate.ExistingLocationID
? 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)
{
@ -251,6 +297,23 @@ public sealed class StoryIntelligenceLocationImportService(
linkedExisting++;
}
if (linkExisting && locationId.HasValue)
{
var primaryName = existingIndex.Find(locationId.Value)?.LocationName ?? candidate.ExistingLocationName ?? importName;
if (await TryAddAliasAsync(locationId.Value, candidate.DisplayName, primaryName))
{
aliasesAdded++;
}
foreach (var alias in candidate.Aliases)
{
if (await TryAddAliasAsync(locationId.Value, alias, primaryName))
{
aliasesAdded++;
}
}
}
AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value);
AddResolvedName(resolvedNames, candidate.LeafName, locationId.Value);
AddResolvedName(resolvedNames, importName, locationId.Value);
@ -282,7 +345,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase))
if (IsPendingSameLocationChoice(choice))
{
continue;
}
@ -293,7 +356,7 @@ public sealed class StoryIntelligenceLocationImportService(
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|| !IsPendingSameLocationChoice(choice))
{
continue;
}
@ -303,7 +366,7 @@ public sealed class StoryIntelligenceLocationImportService(
|| 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)
|| IsPendingSameLocationChoice(targetChoice)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId))
{
@ -321,7 +384,7 @@ public sealed class StoryIntelligenceLocationImportService(
}
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)
@ -332,7 +395,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue;
}
var locationId = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
var locationId = IsPendingSameLocationChoice(choice)
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
: resolvedCandidateIds.GetValueOrDefault(candidate.Key);
if (locationId <= 0)
@ -340,18 +403,7 @@ public sealed class StoryIntelligenceLocationImportService(
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++;
}
sceneLocationsUpdated += await LinkSceneLocationsAsync(candidate, locationId);
}
batch.LocationStageComplete = completeReview;
@ -920,6 +972,31 @@ public sealed class StoryIntelligenceLocationImportService(
})
.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)
{
var index = new LocationIndex();
@ -936,6 +1013,34 @@ public sealed class StoryIntelligenceLocationImportService(
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)
{
var cleanAlias = Clean(alias);
@ -1559,8 +1664,9 @@ public sealed class StoryIntelligenceLocationImportService(
private sealed class LocationIndex
{
private readonly Dictionary<string, LocationMatch> byName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, LocationMatch> byParentAndName = new(StringComparer.OrdinalIgnoreCase);
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)
{
@ -1568,8 +1674,9 @@ public sealed class StoryIntelligenceLocationImportService(
if (!string.IsNullOrWhiteSpace(key))
{
var match = new LocationMatch(locationId, locationName, parentLocationId, parentLocationName, locationPath);
byName.TryAdd(key, match);
byParentAndName.TryAdd(ParentAwareCandidateKey(parentLocationId?.ToString(), key), match);
byId.TryAdd(locationId, match);
AddMatch(byName, key, match);
AddMatch(byParentAndName, ParentAwareCandidateKey(parentLocationId?.ToString(), key), match);
}
}
@ -1583,12 +1690,37 @@ public sealed class StoryIntelligenceLocationImportService(
if (parentLocationId.HasValue)
{
return byParentAndName.TryGetValue(ParentAwareCandidateKey(parentLocationId.Value.ToString(), key), out var parentMatch)
? parentMatch
: null;
return FindUnambiguous(byParentAndName, ParentAwareCandidateKey(parentLocationId.Value.ToString(), key));
}
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
{
Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(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)
{
await PersistAsync(
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 PrepareLocationCandidatesAsync(batch);
await PersistAsync(
batch,
@ -106,6 +99,31 @@ public sealed class StoryIntelligenceReviewCandidateService(
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)
{
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)
{
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)
.ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
.Select(location => new StoryIntelligenceLocationParentOptionViewModel
@ -169,6 +188,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
LocationPath = location.LocationPath
})
.ToList();
var sameLocationOptions = await BuildSameLocationOptionsAsync(projectLocations);
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = true,
@ -180,6 +200,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
ParentLocationOptions = parentOptions,
SameLocationOptions = sameLocationOptions,
Candidates = page.Items
};
}
@ -277,13 +298,39 @@ public sealed class StoryIntelligenceReviewCandidateService(
Module = StoryIntelligenceReviewModules.Locations,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.ExistingLocationID
});
}
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)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;

View File

@ -454,6 +454,7 @@ public sealed class StoryIntelligenceLocationReviewViewModel
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel> SameLocationOptions { 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 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 string Key { get; init; } = string.Empty;
@ -500,6 +508,7 @@ public sealed class StoryIntelligenceLocationImportChoiceForm
public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew;
public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; }
public int? ExistingLocationID { get; set; }
public int? ParentLocationID { get; set; }
public string? ParentSelection { get; set; }
}

View File

@ -11,7 +11,7 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence</p>
<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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -37,7 +37,7 @@
else
{
<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="collapse">Collapse all</button>
@if (hiddenCandidateCount > 0)
@ -109,12 +109,8 @@
Create new location
</label>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" data-location-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
Link existing location
</label>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Alias" data-location-action />
Alias of another location
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" data-location-action />
Same location as
</label>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Ignore" data-location-action />
@ -156,11 +152,23 @@
</select>
</div>
<div data-location-alias-panel hidden>
<label class="form-label" for="location-alias-target-@i">Alias/variant target</label>
<div data-location-same-panel hidden>
<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>
<option value="">Choose location...</option>
@foreach (var target in locationCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
<option value="">Choose pending location...</option>
@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>
}
@ -210,7 +218,9 @@
const actionFor = (card) => card.querySelector("[data-location-action]:checked")?.value || "@StoryIntelligenceLocationImportActions.CreateNew";
const isCanonicalTarget = (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 allCards = cards();
@ -236,18 +246,18 @@
const updateCard = (card) => {
const action = actionFor(card);
const namePanel = card.querySelector("[data-location-import-name-panel]");
const aliasPanel = card.querySelector("[data-location-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
const samePanel = card.querySelector("[data-location-same-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.LinkExisting";
const parentPanel = card.querySelector("[data-location-parent-panel]");
if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.LinkExisting";
if (samePanel) samePanel.hidden = action !== "@StoryIntelligenceLocationImportActions.LinkExisting";
updateAliasTargets();
};
document.querySelectorAll("[data-location-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-location-bulk");
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");
}
});
@ -258,6 +268,25 @@
form.querySelectorAll("[data-location-action]").forEach(input => {
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);
})();
</script>