Hide exact known character imports from review
This commit is contained in:
parent
b6caf9df8e
commit
45051a8556
@ -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");
|
||||||
|
|||||||
@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
62
PlotLine/Services/CoreImportCharacterCandidateResolver.cs
Normal file
62
PlotLine/Services/CoreImportCharacterCandidateResolver.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user