PlotDirector/PlotLine/Services/StoryIntelligenceCharacterImportService.cs

928 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceCharacterImportService
{
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form);
}
public sealed class StoryIntelligenceCharacterImportService(
IStoryIntelligenceResultRepository runs,
ICharacterRepository characters,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> IgnoredNames = CreateSet(
"narrator", "unknown", "unknown narrator", "i", "me", "my", "mine", "we", "us", "our", "ours",
"you", "he", "him", "his", "she", "her", "hers", "they", "them", "their", "theirs");
private static readonly HashSet<string> GenericPeople = CreateSet(
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
"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(
"christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room",
"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)
{
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceCharacterReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
{
Key = candidate.Key,
CharacterName = candidate.DisplayName,
ImportName = candidate.DisplayName,
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExistingCharacterID = candidate.ExistingCharacterID,
ExistingCharacterName = candidate.ExistingCharacterName
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." };
}
var choices = form.Characters
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
if (choices.Count == 0)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create, link or ignore." };
}
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var roleTypes = lookupData.RoleTypes;
var presenceTypes = lookupData.PresenceTypes;
var created = 0;
var linkedExisting = 0;
var ignored = 0;
var aliased = 0;
var linkedAppearances = 0;
var povLinks = 0;
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)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Ignore, candidate.DisplayName, null, false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = forceCreateSeparate
? null
: candidate.ExistingCharacterID
?? existingIndex.Find(importName)?.CharacterID
?? existingIndex.Find(candidate.DisplayName)?.CharacterID;
var characterId = matchedExistingId;
if (!characterId.HasValue)
{
characterId = await characters.SaveCharacterAsync(new Character
{
ProjectID = batch.ProjectID,
CharacterName = importName,
ShortName = FirstName(importName),
DefaultDescription = candidate.Description
});
created++;
}
else
{
linkedExisting++;
}
AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value);
AddResolvedName(resolvedCharacters, importName, characterId.Value);
foreach (var alias in candidate.Aliases)
{
AddResolvedName(resolvedCharacters, alias, characterId.Value);
await TryAddAliasAsync(characterId.Value, alias, importName);
}
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
{
await characters.SaveSceneCharacterAsync(new SceneCharacter
{
SceneID = appearance.SceneID,
CharacterID = characterId.Value,
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
AppearanceNotes = appearance.Notes
});
linkedAppearances++;
}
resolvedCandidateIds[candidate.Key] = characterId.Value;
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)
{
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
if (string.IsNullOrWhiteSpace(povName) || !resolvedCharacters.TryGetValue(povName, out var characterId))
{
continue;
}
await scenes.UpdatePovCharacterAsync(scene.SceneID, characterId);
povLinks++;
}
batch.CharacterStageComplete = true;
batch.LastCharacterImportResult = new StoryIntelligenceCharacterImportBatchResult
{
CharactersCreated = created,
CharactersLinked = linkedExisting,
CharactersIgnored = ignored,
CharactersAliased = aliased,
PovLinksResolved = povLinks,
ScenePeoplePanelsUpdated = linkedAppearances
};
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
batch.BatchID,
created,
linkedExisting,
aliased,
ignored,
linkedAppearances,
povLinks);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = linkedAppearances,
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."
};
}
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? characterName, int? characterId, bool createdOrLinked)
{
batch.CharacterDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.CharacterDecisions.Add(new OnboardingStoryIntelligenceCharacterDecision
{
Key = key,
Action = action,
CharacterName = characterName,
CharacterID = characterId,
CreatedOrLinked = createdOrLinked
});
}
private async Task<CharacterCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var groups = new Dictionary<string, CharacterCandidate>(StringComparer.OrdinalIgnoreCase);
var sceneAnalyses = new List<ImportedSceneAnalysis>();
var alreadyLinked = 0;
var hasCommittedScenes = false;
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
sceneAnalyses.Add(new ImportedSceneAnalysis(importedScene.SceneID, parsed));
var existingAppearances = await characters.ListSceneCharactersAsync(importedScene.SceneID);
alreadyLinked += existingAppearances.Count;
AddSceneCharacterMentions(groups, existingIndex, importedScene, parsed, existingAppearances);
}
}
var candidates = groups.Values
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
.Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked))
.Where(IsVisibleCharacterCandidate)
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName)
.ToList();
return new CharacterCandidateData(hasCommittedScenes, alreadyLinked, candidates, sceneAnalyses);
}
private static void AddMention(
Dictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
SceneIntelligenceCharacter character,
IReadOnlyList<SceneCharacter> existingAppearances)
{
var name = CleanCharacterName(character.Name);
if (!IsCharacterNameCandidate(name))
{
return;
}
var key = ResolveCandidateKey(groups, existingIndex, name);
if (!groups.TryGetValue(key, out var candidate))
{
var match = existingIndex.Find(name);
candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName);
groups[key] = candidate;
}
else if (!string.Equals(candidate.DisplayName, name, StringComparison.OrdinalIgnoreCase))
{
candidate.Aliases.Add(name);
}
foreach (var alias in character.Aliases ?? [])
{
var cleanAlias = CleanCharacterName(alias);
if (IsCharacterNameCandidate(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
{
candidate.Aliases.Add(cleanAlias);
}
}
var notes = Clean(character.Notes);
if (string.IsNullOrWhiteSpace(notes) && character.Actions?.Count > 0)
{
notes = Clean(string.Join("; ", character.Actions));
}
var appearance = new CharacterAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
character.RoleInScene,
character.MentionedOnly == true,
notes,
character.Confidence,
candidate.ExistingCharacterID.HasValue && existingAppearances.Any(appearance => appearance.CharacterID == candidate.ExistingCharacterID.Value));
if (character.MentionedOnly == true)
{
candidate.MentionedOnlyAppearances.Add(appearance);
}
else
{
candidate.PresentAppearances.Add(appearance);
}
candidate.Confidence = Max(candidate.Confidence, character.Confidence);
candidate.Description ??= notes;
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, character);
}
private static void AddSceneCharacterMentions(
Dictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<SceneCharacter> existingAppearances)
{
foreach (var character in parsed.Characters ?? [])
{
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
}
var povName = CleanCharacterName(parsed.PointOfView?.CharacterName);
if (IsCharacterNameCandidate(povName))
{
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
{
Name = povName,
RoleInScene = "POV",
MentionedOnly = false,
Confidence = parsed.PointOfView?.Confidence ?? 0.7m,
Notes = "Point of view candidate from Scene Intelligence."
}, existingAppearances);
}
foreach (var relationship in parsed.Relationships ?? [])
{
AddObservedName(groups, existingIndex, importedScene, parsed, relationship.CharacterA, "Relationship participant", false, relationship.Confidence, relationship.RelationshipSignal, existingAppearances);
AddObservedName(groups, existingIndex, importedScene, parsed, relationship.CharacterB, "Relationship participant", false, relationship.Confidence, relationship.RelationshipSignal, existingAppearances);
}
foreach (var knowledge in parsed.KnowledgeChanges ?? [])
{
AddObservedName(groups, existingIndex, importedScene, parsed, knowledge.RecipientCharacter, "Knowledge recipient", false, knowledge.Confidence, knowledge.KnowledgeItem, existingAppearances);
AddObservedName(groups, existingIndex, importedScene, parsed, knowledge.SourceCharacter, "Knowledge source", true, knowledge.Confidence, knowledge.KnowledgeItem, existingAppearances);
}
foreach (var observation in parsed.Observations ?? [])
{
if (IsCharacterEntity(observation.SubjectEntityType))
{
AddObservedName(groups, existingIndex, importedScene, parsed, observation.SubjectName, observation.ObservationType, false, observation.Confidence, observation.Description, existingAppearances);
}
if (IsCharacterEntity(observation.ObjectEntityType))
{
AddObservedName(groups, existingIndex, importedScene, parsed, observation.ObjectName, observation.ObservationType, true, observation.Confidence, observation.Description, existingAppearances);
}
}
}
private static void AddObservedName(
Dictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
string? name,
string? role,
bool mentionedOnly,
decimal? confidence,
string? notes,
IReadOnlyList<SceneCharacter> existingAppearances)
{
var cleanName = CleanCharacterName(name);
if (!IsCharacterNameCandidate(cleanName))
{
return;
}
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
{
Name = cleanName,
RoleInScene = role,
MentionedOnly = mentionedOnly,
Confidence = confidence ?? 0.65m,
Notes = notes
}, existingAppearances);
}
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(FirstName(character.CharacterName), character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
index.Add(FirstName(alias.Alias), character.CharacterID, character.CharacterName);
}
}
return index;
}
private async Task TryAddAliasAsync(int characterId, string alias, string primaryName)
{
var cleanAlias = Clean(alias);
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
{
return;
}
var existingAliases = await characters.ListAliasesAsync(characterId);
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
{
return;
}
await characters.AddAliasAsync(characterId, cleanAlias, null);
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
{
return rangeMatch;
}
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
{
return direct;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static int? MatchRole(IReadOnlyList<CharacterRoleInSceneType> roleTypes, string? role)
{
var value = Clean(role);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return roleTypes.FirstOrDefault(type =>
value.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase)
|| type.TypeName.Contains(value, StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID;
}
private static int? MatchPresence(IReadOnlyList<PresenceType> presenceTypes, bool mentionedOnly)
{
var preferred = mentionedOnly ? "Mentioned" : "Present";
return presenceTypes.FirstOrDefault(type => type.TypeName.Contains(preferred, StringComparison.OrdinalIgnoreCase))?.PresenceTypeID;
}
private static void AddResolvedName(Dictionary<string, int> map, string name, int characterId)
{
var clean = Clean(name);
if (!string.IsNullOrWhiteSpace(clean))
{
map[clean] = characterId;
}
}
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)
{
var note = Clean(character.Notes);
if (!string.IsNullOrWhiteSpace(note))
{
return $"Scene {scene.SceneNumber:g}: {note}";
}
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}";
}
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static string? FirstName(string name)
{
var parts = Clean(name).Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length > 1 ? parts[0] : null;
}
private static bool IsGenericPerson(string name)
=> GenericPeople.Contains(name) || GenericPeople.Contains(Normalise(name));
private static bool IsCharacterEntity(string? entityType)
=> 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)
{
var clean = CleanCharacterName(name);
if (string.IsNullOrWhiteSpace(clean)
|| IgnoredNames.Contains(clean)
|| IsGenericPerson(clean)
|| NonCharacterNames.Contains(clean))
{
return false;
}
if (!clean.Any(char.IsLetter) || clean.Length < 2)
{
return false;
}
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(
IReadOnlyDictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
string name)
{
var canonical = CanonicalCharacterKey(name);
if (groups.ContainsKey(canonical))
{
return canonical;
}
var existingMatch = existingIndex.Find(name);
if (existingMatch is not null)
{
var existingKey = CanonicalCharacterKey(existingMatch.CharacterName);
if (groups.ContainsKey(existingKey))
{
return existingKey;
}
}
var first = FirstNameWithoutTitle(name);
if (!string.IsNullOrWhiteSpace(first))
{
foreach (var candidate in groups.Values)
{
if (string.Equals(FirstNameWithoutTitle(candidate.DisplayName), first, StringComparison.OrdinalIgnoreCase)
&& IsSafeFirstNameMerge(candidate.DisplayName, name))
{
return candidate.Key;
}
}
}
return canonical;
}
private static bool IsSafeFirstNameMerge(string current, string next)
{
var currentWords = WordsWithoutTitles(current);
var nextWords = WordsWithoutTitles(next);
return currentWords.Count == 1 || nextWords.Count == 1 || currentWords.SequenceEqual(nextWords, StringComparer.OrdinalIgnoreCase);
}
private static string CanonicalCharacterKey(string name)
=> Normalise(string.Join(' ', WordsWithoutTitles(CleanCharacterName(name))));
private static string? FirstNameWithoutTitle(string? name)
=> WordsWithoutTitles(name).FirstOrDefault();
private static List<string> WordsWithoutTitles(string? name)
{
var words = CleanCharacterName(name)
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(word => word.Trim('.', ',', ';', ':', '!', '?', '"', '\''))
.Where(word => !string.IsNullOrWhiteSpace(word))
.ToList();
if (words.Count > 1 && IsMergeableTitle(words[0]))
{
words.RemoveAt(0);
}
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)
=> value.Equals("instructor", StringComparison.OrdinalIgnoreCase)
|| value.Equals("detective", StringComparison.OrdinalIgnoreCase)
|| value.Equals("sergeant", StringComparison.OrdinalIgnoreCase)
|| value.Equals("ds", StringComparison.OrdinalIgnoreCase);
private static string CleanCharacterName(string? value)
{
var clean = Clean(value)
.Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
if (clean.EndsWith("'s", StringComparison.OrdinalIgnoreCase))
{
clean = clean[..^2];
}
else if (clean.EndsWith("s", StringComparison.OrdinalIgnoreCase))
{
clean = clean[..^2];
}
return Clean(clean.Replace("Mrs.", "Mrs", StringComparison.OrdinalIgnoreCase)
.Replace("Mr.", "Mr", StringComparison.OrdinalIgnoreCase)
.Replace("Ms.", "Ms", StringComparison.OrdinalIgnoreCase)
.Replace("Dr.", "Dr", StringComparison.OrdinalIgnoreCase));
}
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Normalise(name);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
}
}
public CharacterMatch? Find(string name)
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
}
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
{
public string Key { get; } = key;
public string DisplayName { get; } = displayName;
public int? ExistingCharacterID { get; } = existingCharacterId;
public string? ExistingCharacterName { get; } = existingCharacterName;
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<CharacterAppearanceImport> PresentAppearances { get; } = [];
public List<CharacterAppearanceImport> MentionedOnlyAppearances { get; } = [];
public decimal? Confidence { get; set; }
public string? Description { get; set; }
public string? FirstAppearanceNote { get; set; }
}
private sealed record CharacterAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
string? RoleInScene,
bool MentionedOnly,
string? Notes,
decimal? Confidence,
bool AlreadyLinked);
private sealed record ImportedSceneAnalysis(int SceneID, SceneIntelligenceScene Parsed);
private sealed record CharacterCandidateData(
bool HasCommittedScenes,
int AlreadyLinkedCount,
IReadOnlyList<CharacterCandidate> Candidates,
IReadOnlyList<ImportedSceneAnalysis> SceneAnalyses);
}