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 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<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()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -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<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 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)
@ -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)