Hide exact known character imports from review

This commit is contained in:
Nick Beckley 2026-08-31 17:18:29 +00:00
parent b6caf9df8e
commit 45051a8556
4 changed files with 213 additions and 3 deletions

View File

@ -120,6 +120,12 @@ var tests = new (string Name, Action Test)[]
("Core import basic location detector supports pending parent candidates", CoreImportBasicLocationDetectorSupportsPendingParentCandidates), ("Core import basic location detector supports pending parent candidates", CoreImportBasicLocationDetectorSupportsPendingParentCandidates),
("Core import character discovery protects accepted locations", CoreImportCharacterDiscoveryProtectsAcceptedLocations), ("Core import character discovery protects accepted locations", CoreImportCharacterDiscoveryProtectsAcceptedLocations),
("Core import gates characters behind basic location review", CoreImportGatesCharactersBehindBasicLocationReview), ("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 character evidence is candidate specific", CoreImportCharacterEvidenceIsCandidateSpecific),
("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters), ("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters), ("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."); Assert(sql.Contains("CoreImportBasicLocationReview_MarkSkipped", StringComparison.Ordinal), "Basic location review should support explicit skip.");
} }
static void CoreImportHidesExactExistingCharacterNamesFromReview()
{
var candidates = new List<ManuscriptScanCharacterCandidatePreview>
{
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<ManuscriptScanCharacterCandidatePreview>
{
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<ManuscriptScanCharacterCandidatePreview>
{
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<ManuscriptScanCharacterCandidatePreview>
{
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<ManuscriptScanCharacterCandidatePreview>
{
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<ManuscriptScanCharacterEvidenceScene>
{
new() { SceneID = 101, ChapterID = 1, ChapterNumber = 1, SceneNumber = 1, MentionCount = 2, StructuralSummary = "Beth arrives." }
};
var candidates = new List<ManuscriptScanCharacterCandidatePreview>
{
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<ManuscriptScanCharacterEvidenceScene>? 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() static void CoreImportCharacterEvidenceIsCandidateSpecific()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -133,7 +133,9 @@ public sealed class ManuscriptScanCharacterCandidatePreview
public int QualityScore { get; init; } public int QualityScore { get; init; }
public string Category { get; init; } = "PossibleCharacter"; public string Category { get; init; } = "PossibleCharacter";
public string? Reason { get; init; } public string? Reason { get; init; }
public int? ExistingCharacterID { get; init; }
public bool IsExistingCharacterMatch { get; init; } public bool IsExistingCharacterMatch { get; init; }
public bool IsAutoResolvedExistingCharacter { get; init; }
public string? SuggestedImportance { get; init; } public string? SuggestedImportance { get; init; }
public IReadOnlyList<ManuscriptScanCharacterEvidenceScene> EvidenceScenes { get; init; } = []; public IReadOnlyList<ManuscriptScanCharacterEvidenceScene> EvidenceScenes { get; init; } = [];
} }

View File

@ -0,0 +1,62 @@
using PlotLine.Models;
namespace PlotLine.Services;
public sealed record CoreImportExistingCharacterName(int CharacterID, string Name);
public sealed class CoreImportCharacterCandidateResolutionResult
{
public IReadOnlyList<ManuscriptScanCharacterCandidatePreview> Candidates { get; init; } = [];
public int AutoResolvedCount { get; init; }
}
public static class CoreImportCharacterCandidateResolver
{
public static CoreImportCharacterCandidateResolutionResult ResolveExactKnownMatches(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates,
IReadOnlyList<CoreImportExistingCharacterName> 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
};
}
}

View File

@ -789,7 +789,35 @@ public sealed class OnboardingService(
{ {
var protectedLocationNames = await coreImportBasicLocations.GetProtectedLocationNamesAsync(userId, preview.PreviewID); var protectedLocationNames = await coreImportBasicLocations.GetProtectedLocationNamesAsync(userId, preview.PreviewID);
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources, protectedLocationNames); 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<IReadOnlyList<CoreImportExistingCharacterName>> BuildExistingCharacterNameRecordsAsync(int projectId)
{
var names = new List<CoreImportExistingCharacterName>();
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<CoreImportExistingCharacterName> names, int characterId, string? name)
{
var clean = Clean(name);
if (!string.IsNullOrWhiteSpace(clean))
{
names.Add(new CoreImportExistingCharacterName(characterId, clean));
}
} }
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId) public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
@ -822,7 +850,19 @@ public sealed class OnboardingService(
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet(); 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)) .Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey))
.Select(item => .Select(item =>
{ {
@ -847,7 +887,7 @@ public sealed class OnboardingService(
AliasTargetTemporaryCharacterKey = aliasTargetKey, AliasTargetTemporaryCharacterKey = aliasTargetKey,
SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName) SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName)
}; };
}) }))
.ToList(); .ToList();
ValidateScanReview(new ManuscriptScanReviewDecision ValidateScanReview(new ManuscriptScanReviewDecision
{ {
@ -1248,6 +1288,7 @@ public sealed class OnboardingService(
.ToList(); .ToList();
var characters = preview.CharacterCandidates var characters = preview.CharacterCandidates
.Where(candidate => !candidate.IsAutoResolvedExistingCharacter)
.OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category)) .OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
.ThenByDescending(candidate => candidate.QualityScore) .ThenByDescending(candidate => candidate.QualityScore)
.ThenByDescending(candidate => candidate.MentionCount) .ThenByDescending(candidate => candidate.MentionCount)