diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 21fe204..7c834fc 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -120,6 +120,12 @@ var tests = new (string Name, Action Test)[] ("Core import basic location detector supports pending parent candidates", CoreImportBasicLocationDetectorSupportsPendingParentCandidates), ("Core import character discovery protects accepted locations", CoreImportCharacterDiscoveryProtectsAcceptedLocations), ("Core import gates characters behind basic location review", CoreImportGatesCharactersBehindBasicLocationReview), + ("Core import hides exact existing character names from review", CoreImportHidesExactExistingCharacterNamesFromReview), + ("Core import hides exact existing character aliases from review", CoreImportHidesExactExistingCharacterAliasesFromReview), + ("Core import keeps suspected unknown aliases in review", CoreImportKeepsSuspectedUnknownAliasesInReview), + ("Core import keeps ambiguous existing character matches in review", CoreImportKeepsAmbiguousExistingCharacterMatchesInReview), + ("Core import keeps new characters in review", CoreImportKeepsNewCharactersInReview), + ("Core import auto resolved character evidence is preserved", CoreImportAutoResolvedCharacterEvidenceIsPreserved), ("Core import character evidence is candidate specific", CoreImportCharacterEvidenceIsCandidateSpecific), ("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters), ("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters), @@ -3848,6 +3854,105 @@ static void CoreImportGatesCharactersBehindBasicLocationReview() Assert(sql.Contains("CoreImportBasicLocationReview_MarkSkipped", StringComparison.Ordinal), "Basic location review should support explicit skip."); } +static void CoreImportHidesExactExistingCharacterNamesFromReview() +{ + var candidates = new List + { + CharacterCandidate("Beth") + }; + var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Beth")]); + + Assert(result.AutoResolvedCount == 1, "Exact preferred-name matches should be auto-resolved."); + Assert(result.Candidates.Single().ExistingCharacterID == 10, "Auto-resolved candidates should carry the CharacterID."); + Assert(result.Candidates.Single().IsAutoResolvedExistingCharacter, "Auto-resolved exact matches should be hidden from identity review."); +} + +static void CoreImportHidesExactExistingCharacterAliasesFromReview() +{ + var candidates = new List + { + CharacterCandidate("Becks"), + CharacterCandidate("Grace") + }; + var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, + [ + new CoreImportExistingCharacterName(10, "Rebecca"), + new CoreImportExistingCharacterName(10, "Becks"), + new CoreImportExistingCharacterName(11, "Maggie"), + new CoreImportExistingCharacterName(11, "Grace") + ]); + + Assert(result.AutoResolvedCount == 2, "Exact known aliases should be auto-resolved."); + Assert(result.Candidates.Single(item => item.Name == "Becks").ExistingCharacterID == 10, "Becks should resolve through aliases to Rebecca's CharacterID."); + Assert(result.Candidates.Single(item => item.Name == "Grace").ExistingCharacterID == 11, "Grace should resolve through aliases to Maggie's CharacterID."); + Assert(result.Candidates.All(item => item.IsAutoResolvedExistingCharacter), "Exact aliases should not require identity review."); +} + +static void CoreImportKeepsSuspectedUnknownAliasesInReview() +{ + var candidates = new List + { + CharacterCandidate("Gigi") + }; + var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Maggie")]); + + Assert(result.AutoResolvedCount == 0, "A new name suspected to be an existing character should still require author review."); + Assert(!result.Candidates.Single().IsAutoResolvedExistingCharacter, "Unknown aliases should remain visible."); +} + +static void CoreImportKeepsAmbiguousExistingCharacterMatchesInReview() +{ + var candidates = new List + { + CharacterCandidate("Beth") + }; + var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Beth"), new CoreImportExistingCharacterName(11, "Beth")]); + + Assert(result.AutoResolvedCount == 0, "Ambiguous exact matches should still require review."); + Assert(!result.Candidates.Single().IsAutoResolvedExistingCharacter, "Ambiguous matches should remain visible."); +} + +static void CoreImportKeepsNewCharactersInReview() +{ + var candidates = new List + { + CharacterCandidate("Rosie") + }; + var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Beth")]); + + Assert(result.AutoResolvedCount == 0, "Genuinely new characters should not be auto-resolved."); + Assert(!result.Candidates.Single().ExistingCharacterID.HasValue, "New characters should not be assigned an existing CharacterID."); +} + +static void CoreImportAutoResolvedCharacterEvidenceIsPreserved() +{ + var evidence = new List + { + new() { SceneID = 101, ChapterID = 1, ChapterNumber = 1, SceneNumber = 1, MentionCount = 2, StructuralSummary = "Beth arrives." } + }; + var candidates = new List + { + CharacterCandidate("Beth", evidence) + }; + var result = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches(candidates, [new CoreImportExistingCharacterName(10, "Beth")]); + var resolved = result.Candidates.Single(); + + Assert(resolved.IsAutoResolvedExistingCharacter, "Beth should auto-resolve."); + Assert(resolved.EvidenceScenes.Single().SceneID == 101, "Auto-resolution should preserve scene evidence."); + Assert(resolved.EvidenceScenes.Single().MentionCount == 2, "Auto-resolution should preserve mention counts."); +} + +static ManuscriptScanCharacterCandidatePreview CharacterCandidate(string name, IReadOnlyList? evidence = null) + => new() + { + TemporaryCharacterKey = $"candidate-{name.ToLowerInvariant()}", + Name = name, + MentionCount = evidence?.Sum(item => item.MentionCount) ?? 1, + QualityScore = 90, + Category = "ProbableCharacter", + EvidenceScenes = evidence ?? [] + }; + static void CoreImportCharacterEvidenceIsCandidateSpecific() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index fdec7e7..f232a28 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -133,7 +133,9 @@ public sealed class ManuscriptScanCharacterCandidatePreview public int QualityScore { get; init; } public string Category { get; init; } = "PossibleCharacter"; public string? Reason { get; init; } + public int? ExistingCharacterID { get; init; } public bool IsExistingCharacterMatch { get; init; } + public bool IsAutoResolvedExistingCharacter { get; init; } public string? SuggestedImportance { get; init; } public IReadOnlyList EvidenceScenes { get; init; } = []; } diff --git a/PlotLine/Services/CoreImportCharacterCandidateResolver.cs b/PlotLine/Services/CoreImportCharacterCandidateResolver.cs new file mode 100644 index 0000000..3c290a3 --- /dev/null +++ b/PlotLine/Services/CoreImportCharacterCandidateResolver.cs @@ -0,0 +1,62 @@ +using PlotLine.Models; + +namespace PlotLine.Services; + +public sealed record CoreImportExistingCharacterName(int CharacterID, string Name); + +public sealed class CoreImportCharacterCandidateResolutionResult +{ + public IReadOnlyList Candidates { get; init; } = []; + public int AutoResolvedCount { get; init; } +} + +public static class CoreImportCharacterCandidateResolver +{ + public static CoreImportCharacterCandidateResolutionResult ResolveExactKnownMatches( + IReadOnlyList candidates, + IReadOnlyList existingNames) + { + var index = existingNames + .Select(item => new { Key = CoreImportBasicLocationDetector.StableKey(item.Name), item.CharacterID }) + .Where(item => !string.IsNullOrWhiteSpace(item.Key)) + .GroupBy(item => item.Key, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.Select(item => item.CharacterID).Distinct().ToList(), + StringComparer.Ordinal); + + var resolved = 0; + var output = candidates.Select(candidate => + { + var key = CoreImportBasicLocationDetector.StableKey(candidate.Name); + if (!string.IsNullOrWhiteSpace(key) + && index.TryGetValue(key, out var matches) + && matches.Count == 1) + { + resolved++; + return new ManuscriptScanCharacterCandidatePreview + { + TemporaryCharacterKey = candidate.TemporaryCharacterKey, + Name = candidate.Name, + MentionCount = candidate.MentionCount, + QualityScore = candidate.QualityScore, + Category = candidate.Category, + Reason = candidate.Reason, + ExistingCharacterID = matches[0], + IsExistingCharacterMatch = true, + IsAutoResolvedExistingCharacter = true, + SuggestedImportance = candidate.SuggestedImportance, + EvidenceScenes = candidate.EvidenceScenes + }; + } + + return candidate; + }).ToList(); + + return new CoreImportCharacterCandidateResolutionResult + { + Candidates = output, + AutoResolvedCount = resolved + }; + } +} diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 6f81c7d..2f20712 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -789,7 +789,35 @@ public sealed class OnboardingService( { var protectedLocationNames = await coreImportBasicLocations.GetProtectedLocationNamesAsync(userId, preview.PreviewID); var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources, protectedLocationNames); - await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); + var resolution = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches( + characterCandidates, + await BuildExistingCharacterNameRecordsAsync(preview.ProjectID)); + await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, resolution.Candidates); + } + + private async Task> BuildExistingCharacterNameRecordsAsync(int projectId) + { + var names = new List(); + foreach (var character in await characters.ListCharactersAsync(projectId)) + { + AddExistingCharacterName(names, character.CharacterID, character.CharacterName); + AddExistingCharacterName(names, character.CharacterID, character.ShortName); + foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) + { + AddExistingCharacterName(names, character.CharacterID, alias.Alias); + } + } + + return names; + } + + private static void AddExistingCharacterName(ICollection names, int characterId, string? name) + { + var clean = Clean(name); + if (!string.IsNullOrWhiteSpace(clean)) + { + names.Add(new CoreImportExistingCharacterName(characterId, clean)); + } } public async Task GetBuildResultAsync(Guid previewId) @@ -822,7 +850,19 @@ public sealed class OnboardingService( var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet(); - var characterDecisions = form.Characters + var autoResolvedDecisions = preview.CharacterCandidates + .Where(candidate => candidate.IsAutoResolvedExistingCharacter && candidate.ExistingCharacterID.HasValue) + .Select(candidate => new ManuscriptScanCharacterReviewDecision + { + TemporaryCharacterKey = candidate.TemporaryCharacterKey, + Include = true, + Name = Clean(candidate.Name), + Category = candidate.Category, + ExistingCharacterID = candidate.ExistingCharacterID, + SexHint = CharacterSexHintService.Normalise(null, candidate.Name) + }) + .ToList(); + var characterDecisions = autoResolvedDecisions.Concat(form.Characters .Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey)) .Select(item => { @@ -847,7 +887,7 @@ public sealed class OnboardingService( AliasTargetTemporaryCharacterKey = aliasTargetKey, SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName) }; - }) + })) .ToList(); ValidateScanReview(new ManuscriptScanReviewDecision { @@ -1248,6 +1288,7 @@ public sealed class OnboardingService( .ToList(); var characters = preview.CharacterCandidates + .Where(candidate => !candidate.IsAutoResolvedExistingCharacter) .OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category)) .ThenByDescending(candidate => candidate.QualityScore) .ThenByDescending(candidate => candidate.MentionCount)