diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 1d5cf08..7e362c9 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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 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> 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); diff --git a/PlotLine/Services/NaturalStringComparer.cs b/PlotLine/Services/NaturalStringComparer.cs new file mode 100644 index 0000000..75e54ad --- /dev/null +++ b/PlotLine/Services/NaturalStringComparer.cs @@ -0,0 +1,80 @@ +namespace PlotLine.Services; + +public sealed class NaturalStringComparer : IComparer +{ + 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); + } +} diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index 21c4e7f..24e69d4 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -8,10 +8,13 @@ namespace PlotLine.Services; public interface IStoryIntelligenceLocationImportService { + Task ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch); Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview); } +public sealed record StoryIntelligenceLocationAutoResolutionResult(int CandidatesResolved, int SceneLocationsLinked); + public sealed class StoryIntelligenceLocationImportService( IStoryIntelligenceResultRepository runs, ILocationRepository locations, @@ -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 ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch) + { + var data = await BuildCandidateDataAsync(batch); + if (!data.HasCommittedScenes) + { + return new StoryIntelligenceLocationAutoResolutionResult(0, 0); + } + + var existingIndex = await BuildLocationIndexAsync(batch.ProjectID); + var resolvedCandidates = 0; + var sceneLocationsLinked = 0; + + foreach (var candidate in data.Candidates.Where(candidate => IsAutoResolvableKnownIdentity(candidate, existingIndex))) + { + var locationId = candidate.ExistingLocationID!.Value; + sceneLocationsLinked += await LinkSceneLocationsAsync(candidate, locationId); + resolvedCandidates++; + AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.LinkExisting, candidate.DisplayName, locationId, true); + } + + if (resolvedCandidates > 0) + { + logger.LogInformation( + "Automatically resolved Story Intelligence location candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneLocationsLinked={SceneLocationsLinked}", + batch.BatchID, + batch.ProjectID, + batch.BookID, + resolvedCandidates, + sceneLocationsLinked); + } + + return new StoryIntelligenceLocationAutoResolutionResult(resolvedCandidates, sceneLocationsLinked); + } + public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); @@ -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> ToIdentityOptionsAsync(IReadOnlyList projectLocations) + { + var options = new List(); + foreach (var location in projectLocations) + { + var aliasNames = (await locations.ListAliasesAsync(location.LocationID)) + .Select(alias => alias.Alias) + .Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase) + .ToList(); + options.Add(new StoryIntelligenceLocationIdentityOptionViewModel + { + LocationID = location.LocationID, + LocationName = location.LocationName, + AliasSummary = string.Join(", ", aliasNames.Take(4)) + }); + } + + return options + .OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase) + .ThenBy(location => location.LocationID) + .ToList(); + } + private async Task BuildLocationIndexAsync(int projectId) { var index = new LocationIndex(); @@ -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 LinkSceneLocationsAsync(LocationCandidate candidate, int locationId) + { + var updated = 0; + foreach (var appearance in candidate.Appearances.Where(appearance => appearance.PresentInScene && !appearance.AlreadyLinked)) + { + var scene = await scenes.GetAsync(appearance.SceneID); + if (scene is null || scene.PrimaryLocationID.HasValue) + { + continue; + } + + scene.PrimaryLocationID = locationId; + await scenes.SaveAsync(scene); + updated++; + } + + return updated; + } + private async Task TryAddAliasAsync(int locationId, string alias, string primaryName) { var cleanAlias = Clean(alias); @@ -1559,8 +1664,9 @@ public sealed class StoryIntelligenceLocationImportService( private sealed class LocationIndex { - private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary byParentAndName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> byName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> byParentAndName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary byId = new(); public void Add(string? name, int locationId, string locationName, int? parentLocationId, string? parentLocationName, string? locationPath) { @@ -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> index, string key, LocationMatch match) + { + if (!index.TryGetValue(key, out var matches)) + { + matches = []; + index[key] = matches; + } + + if (matches.All(existing => existing.LocationID != match.LocationID)) + { + matches.Add(match); + } + } + + private static LocationMatch? FindUnambiguous(Dictionary> index, string key) + { + if (!index.TryGetValue(key, out var matches)) + { + return null; + } + + return matches.Select(match => match.LocationID).Distinct().Count() == 1 ? matches[0] : null; } } diff --git a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs index 69c250a..95ccf3d 100644 --- a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs +++ b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs @@ -9,6 +9,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceReviewCandidateService { Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch); + Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); Task 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 GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize); @@ -159,7 +177,8 @@ public sealed class StoryIntelligenceReviewCandidateService( public async Task GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var page = await LoadPageAsync(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> BuildSameLocationOptionsAsync(IReadOnlyList projectLocations) + { + var options = new List(); + foreach (var location in projectLocations) + { + var aliasNames = (await locations.ListAliasesAsync(location.LocationID)) + .Select(alias => alias.Alias) + .Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase) + .ToList(); + options.Add(new StoryIntelligenceLocationIdentityOptionViewModel + { + LocationID = location.LocationID, + LocationName = location.LocationName, + AliasSummary = string.Join(", ", aliasNames.Take(4)) + }); + } + + return options + .OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase) + .ThenBy(location => location.LocationID) + .ToList(); + } + public async Task MarkAssetDecisionsAsync(int bookId, IEnumerable choices) { StoryIntelligenceReviewCandidateGeneration? generation = null; diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 044e41d..0291d67 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -454,6 +454,7 @@ public sealed class StoryIntelligenceLocationReviewViewModel public bool HasPreviousPage => PageNumber > 1; public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount; public IReadOnlyList ParentLocationOptions { get; init; } = []; + public IReadOnlyList SameLocationOptions { get; init; } = []; public IReadOnlyList 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; } } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml index 04a9fa2..874de70 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml @@ -11,7 +11,7 @@

Story Intelligence

Review Locations

-

Review place suggestions that need a create, link, alias, or ignore decision.

+

Review place suggestions that need a create, same-location, or ignore decision.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -37,7 +37,7 @@ else {
- + @if (hiddenCandidateCount > 0) @@ -109,12 +109,8 @@ Create new location -
-