Phase 20AM – Character Review Alias Handling and Candidate Filtering
This commit is contained in:
parent
4fee5846ab
commit
f4897e5d5f
@ -10,7 +10,9 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Truncated JSON is rejected", RejectsTruncatedJson),
|
("Truncated JSON is rejected", RejectsTruncatedJson),
|
||||||
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
|
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
|
||||||
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
|
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
|
||||||
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput)
|
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
|
||||||
|
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
|
||||||
|
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var test in tests)
|
foreach (var test in tests)
|
||||||
@ -106,6 +108,42 @@ static void SharedParserReportsRawOutput()
|
|||||||
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
|
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void CharacterFilteringRejectsGenericGroups()
|
||||||
|
{
|
||||||
|
Assert(IsGenericGroupReference("four examiners"), "Quantity + generic group should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("groups of men"), "Generic group phrase should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("neighbours"), "Generic plural group should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("passenger (blue Volkswagen Golf GTi)"), "Unnamed passenger descriptor should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("tubby lad"), "Unnamed descriptive person should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("lady examiner"), "Unnamed role descriptor should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("her mother"), "Determiner + family role should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("the child"), "Determiner + generic person should be rejected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void CharacterFilteringPreservesTitledNames()
|
||||||
|
{
|
||||||
|
Assert(!IsGenericGroupReference("Reverend Potter"), "Reverend Potter should not be treated as generic.");
|
||||||
|
Assert(IsNamedOrTitledPerson("Reverend Potter"), "Reverend Potter should be recognised as a titled person.");
|
||||||
|
Assert(IsNamedOrTitledPerson("Detective Sergeant Summerhill"), "Detective Sergeant Summerhill should be recognised as a titled person.");
|
||||||
|
Assert(IsNamedOrTitledPerson("Mrs Patterson"), "Mrs Patterson should be recognised as a titled person.");
|
||||||
|
Assert(IsNamedOrTitledPerson("Aunt Elen"), "Aunt Elen should be recognised as a titled person.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsGenericGroupReference(string name)
|
||||||
|
=> InvokePrivateCharacterFilter("IsGenericGroupReference", name);
|
||||||
|
|
||||||
|
static bool IsNamedOrTitledPerson(string name)
|
||||||
|
=> InvokePrivateCharacterFilter("IsNamedOrTitledPerson", name);
|
||||||
|
|
||||||
|
static bool InvokePrivateCharacterFilter(string methodName, string name)
|
||||||
|
{
|
||||||
|
var method = typeof(StoryIntelligenceCharacterImportService).GetMethod(
|
||||||
|
methodName,
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert(method is not null, $"{methodName} was not found.");
|
||||||
|
return method!.Invoke(null, [name]) is true;
|
||||||
|
}
|
||||||
|
|
||||||
static JsonSerializerOptions JsonOptions()
|
static JsonSerializerOptions JsonOptions()
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -370,6 +370,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
BookID = batch.BookID,
|
BookID = batch.BookID,
|
||||||
CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0,
|
CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0,
|
||||||
CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0,
|
CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0,
|
||||||
|
CharactersAliased = batch.LastCharacterImportResult?.CharactersAliased ?? 0,
|
||||||
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
|
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
|
||||||
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
|
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
|
||||||
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0
|
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0
|
||||||
@ -711,6 +712,7 @@ public sealed class StoryIntelligenceCharacterImportBatchResult
|
|||||||
{
|
{
|
||||||
public int CharactersCreated { get; init; }
|
public int CharactersCreated { get; init; }
|
||||||
public int CharactersLinked { get; init; }
|
public int CharactersLinked { get; init; }
|
||||||
|
public int CharactersAliased { get; init; }
|
||||||
public int CharactersIgnored { get; init; }
|
public int CharactersIgnored { get; init; }
|
||||||
public int PovLinksResolved { get; init; }
|
public int PovLinksResolved { get; init; }
|
||||||
public int ScenePeoplePanelsUpdated { get; init; }
|
public int ScenePeoplePanelsUpdated { get; init; }
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.ViewModels;
|
using PlotLine.ViewModels;
|
||||||
@ -31,11 +32,38 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
private static readonly HashSet<string> GenericPeople = CreateSet(
|
private static readonly HashSet<string> GenericPeople = CreateSet(
|
||||||
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
|
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
|
||||||
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
|
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
|
||||||
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group", "police");
|
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group", "police", "officer",
|
||||||
|
"officers", "staff", "customers", "boys", "girls", "men", "women", "neighbour", "neighbours",
|
||||||
|
"passenger", "passengers", "examiner", "examiners", "instructor", "instructors", "candidate", "candidates",
|
||||||
|
"social worker", "social workers", "responders");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> GenericGroupHeads = CreateSet(
|
||||||
|
"man", "men", "woman", "women", "boy", "boys", "girl", "girls", "child", "children", "person", "people",
|
||||||
|
"group", "crowd", "family", "neighbour", "neighbours", "passenger", "passengers", "examiner", "examiners",
|
||||||
|
"driver", "drivers", "instructor", "instructors", "candidate", "candidates", "officer", "officers", "staff", "customers",
|
||||||
|
"responders", "worker", "workers", "lad", "lads");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> QuantityWords = CreateSet(
|
||||||
|
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "several", "some",
|
||||||
|
"many", "few", "couple", "various", "other", "groups");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> DeterminerWords = CreateSet(
|
||||||
|
"the", "a", "an", "my", "your", "his", "her", "our", "their");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> FamilyRoleNames = CreateSet(
|
||||||
|
"aunt", "uncle", "mother", "father", "mum", "mom", "mam", "dad", "brother", "sister", "grandmother",
|
||||||
|
"grandfather", "grandma", "grandad", "granddad");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> CharacterTitlePrefixes = CreateSet(
|
||||||
|
"mr", "mrs", "miss", "ms", "dr", "reverend", "rev", "detective", "sergeant", "ds", "aunt", "uncle");
|
||||||
|
|
||||||
private static readonly HashSet<string> NonCharacterNames = CreateSet(
|
private static readonly HashSet<string> NonCharacterNames = CreateSet(
|
||||||
"christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room",
|
"christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room",
|
||||||
"hallway", "kitchen", "garage", "pavement", "road", "desk", "table", "phone", "letter", "note");
|
"hallway", "kitchen", "garage", "pavement", "road", "desk", "table", "phone", "letter", "note",
|
||||||
|
"social services", "under-flyover / link road");
|
||||||
|
|
||||||
|
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex LeadingQuantity = new(@"^\s*(\d+|one|two|three|four|five|six|seven|eight|nine|ten|several|some|many|few|couple|various|other)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
@ -86,9 +114,12 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var created = 0;
|
var created = 0;
|
||||||
var linkedExisting = 0;
|
var linkedExisting = 0;
|
||||||
var ignored = 0;
|
var ignored = 0;
|
||||||
|
var aliased = 0;
|
||||||
var linkedAppearances = 0;
|
var linkedAppearances = 0;
|
||||||
var povLinks = 0;
|
var povLinks = 0;
|
||||||
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var candidate in data.Candidates)
|
foreach (var candidate in data.Candidates)
|
||||||
{
|
{
|
||||||
@ -104,6 +135,11 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var requestedName = Clean(choice.ImportName);
|
var requestedName = Clean(choice.ImportName);
|
||||||
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
||||||
var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase);
|
var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase);
|
||||||
@ -151,9 +187,60 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
linkedAppearances++;
|
linkedAppearances++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvedCandidateIds[candidate.Key] = characterId.Value;
|
||||||
AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true);
|
AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var candidate in data.Candidates)
|
||||||
|
{
|
||||||
|
if (!choices.TryGetValue(candidate.Key, out var choice)
|
||||||
|
|| !string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetKey = Clean(choice.AliasTargetKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(targetKey)
|
||||||
|
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
||||||
|
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|
||||||
|
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Story Intelligence character alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
|
||||||
|
candidate.Key,
|
||||||
|
targetKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
|
||||||
|
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
|
||||||
|
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
|
||||||
|
foreach (var alias in candidate.Aliases)
|
||||||
|
{
|
||||||
|
await TryAddAliasAsync(targetCharacterId, alias, targetName);
|
||||||
|
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
||||||
|
{
|
||||||
|
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||||||
|
{
|
||||||
|
SceneID = appearance.SceneID,
|
||||||
|
CharacterID = targetCharacterId,
|
||||||
|
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||||||
|
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||||||
|
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
|
||||||
|
});
|
||||||
|
linkedAppearances++;
|
||||||
|
}
|
||||||
|
|
||||||
|
aliased++;
|
||||||
|
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId, true);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var scene in data.SceneAnalyses)
|
foreach (var scene in data.SceneAnalyses)
|
||||||
{
|
{
|
||||||
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
|
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
|
||||||
@ -172,16 +259,18 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
CharactersCreated = created,
|
CharactersCreated = created,
|
||||||
CharactersLinked = linkedExisting,
|
CharactersLinked = linkedExisting,
|
||||||
CharactersIgnored = ignored,
|
CharactersIgnored = ignored,
|
||||||
|
CharactersAliased = aliased,
|
||||||
PovLinksResolved = povLinks,
|
PovLinksResolved = povLinks,
|
||||||
ScenePeoplePanelsUpdated = linkedAppearances
|
ScenePeoplePanelsUpdated = linkedAppearances
|
||||||
};
|
};
|
||||||
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
|
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
|
||||||
batch.BatchID,
|
batch.BatchID,
|
||||||
created,
|
created,
|
||||||
linkedExisting,
|
linkedExisting,
|
||||||
|
aliased,
|
||||||
ignored,
|
ignored,
|
||||||
linkedAppearances,
|
linkedAppearances,
|
||||||
povLinks);
|
povLinks);
|
||||||
@ -190,7 +279,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
ScenesCreated = linkedAppearances,
|
ScenesCreated = linkedAppearances,
|
||||||
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved."
|
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,6 +347,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var candidates = groups.Values
|
var candidates = groups.Values
|
||||||
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
|
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
|
||||||
.Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked))
|
.Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked))
|
||||||
|
.Where(IsVisibleCharacterCandidate)
|
||||||
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||||
.ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
.ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||||
.ThenBy(candidate => candidate.DisplayName)
|
.ThenBy(candidate => candidate.DisplayName)
|
||||||
@ -515,6 +605,12 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? BuildAliasAppearanceNote(string aliasName, string targetName, string? notes)
|
||||||
|
{
|
||||||
|
var prefix = $"Imported as alias '{aliasName}' of {targetName}.";
|
||||||
|
return string.IsNullOrWhiteSpace(notes) ? prefix : $"{prefix} {notes}";
|
||||||
|
}
|
||||||
|
|
||||||
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
|
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
|
||||||
{
|
{
|
||||||
var note = Clean(character.Notes);
|
var note = Clean(character.Notes);
|
||||||
@ -551,6 +647,30 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
private static bool IsCharacterEntity(string? entityType)
|
private static bool IsCharacterEntity(string? entityType)
|
||||||
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IsVisibleCharacterCandidate(CharacterCandidate candidate)
|
||||||
|
{
|
||||||
|
var name = CleanCharacterName(candidate.DisplayName);
|
||||||
|
if (!IsCharacterNameCandidate(name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsNamedOrTitledPerson(name))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsFamilyRoleOnly(name))
|
||||||
|
{
|
||||||
|
return candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances)
|
||||||
|
.Select(appearance => appearance.SceneID)
|
||||||
|
.Distinct()
|
||||||
|
.Count() >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !IsGenericGroupReference(name);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsCharacterNameCandidate(string? name)
|
private static bool IsCharacterNameCandidate(string? name)
|
||||||
{
|
{
|
||||||
var clean = CleanCharacterName(name);
|
var clean = CleanCharacterName(name);
|
||||||
@ -570,6 +690,80 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsGenericGroupReference(string? name)
|
||||||
|
{
|
||||||
|
var clean = CleanCharacterName(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(clean))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var withoutParentheses = Clean(ParentheticalSuffix.Replace(clean, string.Empty));
|
||||||
|
var normalised = Normalise(withoutParentheses);
|
||||||
|
if (GenericPeople.Contains(normalised) || NonCharacterNames.Contains(normalised))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var words = WordsForFiltering(withoutParentheses);
|
||||||
|
if (words.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LeadingQuantity.IsMatch(withoutParentheses)
|
||||||
|
&& words.Any(word => GenericGroupHeads.Contains(word)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words.Count > 1
|
||||||
|
&& words.Any(word => GenericGroupHeads.Contains(word))
|
||||||
|
&& !HasSpecificPossessiveFamilyReference(words))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words.Count > 1
|
||||||
|
&& DeterminerWords.Contains(words[0])
|
||||||
|
&& words.Skip(1).Any(word => FamilyRoleNames.Contains(word) || GenericGroupHeads.Contains(word)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words.Count > 1
|
||||||
|
&& QuantityWords.Contains(words[0])
|
||||||
|
&& words.Skip(1).Any(word => GenericGroupHeads.Contains(word)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return words.All(word => GenericGroupHeads.Contains(word) || QuantityWords.Contains(word));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNamedOrTitledPerson(string name)
|
||||||
|
{
|
||||||
|
var words = WordsForFiltering(name);
|
||||||
|
if (words.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CharacterTitlePrefixes.Contains(words[0]) && words.Count > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFamilyRoleOnly(string name)
|
||||||
|
{
|
||||||
|
var words = WordsForFiltering(name);
|
||||||
|
return words.Count == 1 && FamilyRoleNames.Contains(words[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasSpecificPossessiveFamilyReference(IReadOnlyList<string> words)
|
||||||
|
=> words.Count == 2
|
||||||
|
&& FamilyRoleNames.Contains(words[1])
|
||||||
|
&& words[0].EndsWith("s", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !DeterminerWords.Contains(words[0]);
|
||||||
|
|
||||||
private static string ResolveCandidateKey(
|
private static string ResolveCandidateKey(
|
||||||
IReadOnlyDictionary<string, CharacterCandidate> groups,
|
IReadOnlyDictionary<string, CharacterCandidate> groups,
|
||||||
CharacterIndex existingIndex,
|
CharacterIndex existingIndex,
|
||||||
@ -636,6 +830,15 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
return words;
|
return words;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<string> WordsForFiltering(string? name)
|
||||||
|
=> CleanCharacterName(name)
|
||||||
|
.Replace("/", " ", StringComparison.Ordinal)
|
||||||
|
.Replace("-", " ", StringComparison.Ordinal)
|
||||||
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(word => word.Trim('.', ',', ';', ':', '!', '?', '"', '\'', '(', ')').ToLowerInvariant())
|
||||||
|
.Where(word => !string.IsNullOrWhiteSpace(word))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private static bool IsMergeableTitle(string value)
|
private static bool IsMergeableTitle(string value)
|
||||||
=> value.Equals("instructor", StringComparison.OrdinalIgnoreCase)
|
=> value.Equals("instructor", StringComparison.OrdinalIgnoreCase)
|
||||||
|| value.Equals("detective", StringComparison.OrdinalIgnoreCase)
|
|| value.Equals("detective", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|||||||
@ -204,6 +204,7 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm
|
|||||||
public string Key { get; set; } = string.Empty;
|
public string Key { get; set; } = string.Empty;
|
||||||
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.Approve;
|
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.Approve;
|
||||||
public string? ImportName { get; set; }
|
public string? ImportName { get; set; }
|
||||||
|
public string? AliasTargetKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class StoryIntelligenceCharacterImportActions
|
public static class StoryIntelligenceCharacterImportActions
|
||||||
@ -211,6 +212,7 @@ public static class StoryIntelligenceCharacterImportActions
|
|||||||
public const string Approve = "Approve";
|
public const string Approve = "Approve";
|
||||||
public const string Ignore = "Ignore";
|
public const string Ignore = "Ignore";
|
||||||
public const string CreateSeparate = "CreateSeparate";
|
public const string CreateSeparate = "CreateSeparate";
|
||||||
|
public const string Alias = "Alias";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCompletionViewModel
|
public sealed class StoryIntelligenceCompletionViewModel
|
||||||
@ -235,6 +237,7 @@ public sealed class StoryIntelligenceCharacterImportResultViewModel
|
|||||||
public int BookID { get; init; }
|
public int BookID { get; init; }
|
||||||
public int CharactersCreated { get; init; }
|
public int CharactersCreated { get; init; }
|
||||||
public int CharactersLinked { get; init; }
|
public int CharactersLinked { get; init; }
|
||||||
|
public int CharactersAliased { get; init; }
|
||||||
public int CharactersIgnored { get; init; }
|
public int CharactersIgnored { get; init; }
|
||||||
public int PovLinksResolved { get; init; }
|
public int PovLinksResolved { get; init; }
|
||||||
public int ScenePeoplePanelsUpdated { get; init; }
|
public int ScenePeoplePanelsUpdated { get; init; }
|
||||||
|
|||||||
@ -24,6 +24,10 @@
|
|||||||
<span>Characters linked</span>
|
<span>Characters linked</span>
|
||||||
<strong>@Model.CharactersLinked.ToString("N0")</strong>
|
<strong>@Model.CharactersLinked.ToString("N0")</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Aliases added</span>
|
||||||
|
<strong>@Model.CharactersAliased.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Characters ignored</span>
|
<span>Characters ignored</span>
|
||||||
<strong>@Model.CharactersIgnored.ToString("N0")</strong>
|
<strong>@Model.CharactersIgnored.ToString("N0")</strong>
|
||||||
|
|||||||
@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
|
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
|
||||||
|
|
||||||
<div class="story-character-card__body">
|
<div class="story-character-card__body" data-character-key="@candidate.Key" data-character-name="@candidate.CharacterName">
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
|
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
|
||||||
<div><dt>Possible aliases</dt><dd>@(candidate.PossibleAliases.Count == 0 ? "None detected" : string.Join(", ", candidate.PossibleAliases))</dd></div>
|
<div><dt>Possible aliases</dt><dd>@(candidate.PossibleAliases.Count == 0 ? "None detected" : string.Join(", ", candidate.PossibleAliases))</dd></div>
|
||||||
@ -101,10 +101,27 @@
|
|||||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action />
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action />
|
||||||
Ignore
|
Ignore
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Alias" data-character-action />
|
||||||
|
Alias of another character
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<label class="form-label" for="character-import-name-@i">Import name</label>
|
<div data-character-import-name-panel>
|
||||||
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" />
|
<label class="form-label" for="character-import-name-@i">Import name</label>
|
||||||
|
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-character-alias-panel hidden>
|
||||||
|
<label class="form-label" for="character-alias-target-@i">Alias target</label>
|
||||||
|
<select id="character-alias-target-@i" class="form-select" name="Characters[@i].AliasTargetKey" data-character-alias-target>
|
||||||
|
<option value="">Choose character...</option>
|
||||||
|
@foreach (var target in Model.CharacterReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
<option value="@target.Key">@target.CharacterName@(target.IsExistingMatch ? $" -> {target.ExistingCharacterName}" : string.Empty)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
@ -129,7 +146,44 @@
|
|||||||
const cards = () => Array.from(form.querySelectorAll("[data-character-card]"));
|
const cards = () => Array.from(form.querySelectorAll("[data-character-card]"));
|
||||||
const setAction = (card, action) => {
|
const setAction = (card, action) => {
|
||||||
const input = card.querySelector(`[data-character-action][value="${action}"]`);
|
const input = card.querySelector(`[data-character-action][value="${action}"]`);
|
||||||
if (input) input.checked = true;
|
if (input) {
|
||||||
|
input.checked = true;
|
||||||
|
updateCard(card);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const actionFor = (card) => card.querySelector("[data-character-action]:checked")?.value || "@StoryIntelligenceCharacterImportActions.Approve";
|
||||||
|
const isCanonicalTarget = (card) => {
|
||||||
|
const action = actionFor(card);
|
||||||
|
return action !== "@StoryIntelligenceCharacterImportActions.Ignore" && action !== "@StoryIntelligenceCharacterImportActions.Alias";
|
||||||
|
};
|
||||||
|
const updateAliasTargets = () => {
|
||||||
|
const allCards = cards();
|
||||||
|
for (const card of allCards) {
|
||||||
|
const body = card.querySelector("[data-character-key]");
|
||||||
|
const ownKey = body?.getAttribute("data-character-key") || "";
|
||||||
|
const select = card.querySelector("[data-character-alias-target]");
|
||||||
|
if (!select) continue;
|
||||||
|
|
||||||
|
const previous = select.value;
|
||||||
|
for (const option of Array.from(select.options)) {
|
||||||
|
if (!option.value) continue;
|
||||||
|
const targetCard = allCards.find(item => item.querySelector("[data-character-key]")?.getAttribute("data-character-key") === option.value);
|
||||||
|
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
|
||||||
|
}
|
||||||
|
if (select.selectedOptions[0]?.disabled) {
|
||||||
|
select.value = "";
|
||||||
|
} else {
|
||||||
|
select.value = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateCard = (card) => {
|
||||||
|
const action = actionFor(card);
|
||||||
|
const namePanel = card.querySelector("[data-character-import-name-panel]");
|
||||||
|
const aliasPanel = card.querySelector("[data-character-alias-panel]");
|
||||||
|
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias";
|
||||||
|
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias";
|
||||||
|
updateAliasTargets();
|
||||||
};
|
};
|
||||||
document.querySelectorAll("[data-character-bulk]").forEach(button => {
|
document.querySelectorAll("[data-character-bulk]").forEach(button => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
@ -142,6 +196,10 @@
|
|||||||
if (action === "collapse") cards().forEach(card => card.open = false);
|
if (action === "collapse") cards().forEach(card => card.open = false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
form.querySelectorAll("[data-character-action]").forEach(input => {
|
||||||
|
input.addEventListener("change", () => updateCard(input.closest("[data-character-card]")));
|
||||||
|
});
|
||||||
|
cards().forEach(updateCard);
|
||||||
form.addEventListener("submit", () => {
|
form.addEventListener("submit", () => {
|
||||||
cards()
|
cards()
|
||||||
.filter(card => !card.querySelector("[data-character-selected]")?.checked)
|
.filter(card => !card.querySelector("[data-character-selected]")?.checked)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user