using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface ICoreImportCharacterMappingService { Task MapBookAsync(int projectId, int bookId); } public sealed class CoreImportCharacterMappingService( IOnboardingBuildRepository builds, ICharacterRepository characters, ILogger logger) : ICoreImportCharacterMappingService { private const string MatchSource = "CoreImportDeterministic"; private static readonly HashSet GenericReferences = new(StringComparer.OrdinalIgnoreCase) { "aunt", "boy", "brother", "child", "children", "dad", "daddy", "doctor", "examiner", "father", "friend", "girl", "grandfather", "grandmother", "he", "him", "his", "lady", "man", "miss", "mother", "mum", "mummy", "officer", "parent", "professor", "she", "sister", "sir", "son", "teacher", "uncle", "woman" }; public async Task MapBookAsync(int projectId, int bookId) { var sourceScenes = await builds.ListSceneManuscriptSourcesByBookAsync(bookId); if (sourceScenes.Count == 0) { return new CoreImportCharacterMappingResult(0, 0); } var projectCharacters = (await characters.ListCharactersAsync(projectId)) .Where(character => !character.IsArchived && !string.IsNullOrWhiteSpace(character.CharacterName)) .ToList(); if (projectCharacters.Count == 0) { return new CoreImportCharacterMappingResult(sourceScenes.Count, 0); } var aliases = new Dictionary>(); foreach (var character in projectCharacters) { aliases[character.CharacterID] = await characters.ListAliasesAsync(character.CharacterID); } var matchIndex = BuildMatchIndex(projectCharacters, aliases); var lookup = await characters.GetLookupsAsync(projectId); var roleTypeId = lookup.RoleTypes.FirstOrDefault(item => string.Equals(item.TypeName, "Mentioned Only", StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID ?? lookup.RoleTypes.FirstOrDefault()?.CharacterRoleInSceneTypeID; var presenceTypeId = lookup.PresenceTypes.FirstOrDefault(item => string.Equals(item.TypeName, "Mentioned Only", StringComparison.OrdinalIgnoreCase))?.PresenceTypeID ?? lookup.PresenceTypes.FirstOrDefault(item => string.Equals(item.TypeName, "Present", StringComparison.OrdinalIgnoreCase))?.PresenceTypeID ?? lookup.PresenceTypes.FirstOrDefault()?.PresenceTypeID; var saved = 0; foreach (var scene in sourceScenes) { foreach (var match in MatchScene(scene.SourceText, matchIndex).OrderBy(item => item.CharacterID)) { await characters.SaveSceneCharacterAsync(new SceneCharacter { SceneID = scene.SceneID, CharacterID = match.CharacterID, RoleInSceneTypeID = roleTypeId, PresenceTypeID = presenceTypeId, MatchSource = MatchSource, MatchedText = match.MatchedText, OccurrenceCount = match.OccurrenceCount, FirstOccurrenceIndex = match.FirstOccurrenceIndex }); saved++; } } logger.LogInformation( "Core import deterministic character mapping completed. ProjectID={ProjectID} BookID={BookID} Scenes={SceneCount} LinksSaved={LinksSaved}", projectId, bookId, sourceScenes.Count, saved); return new CoreImportCharacterMappingResult(sourceScenes.Count, saved); } internal static IReadOnlyList BuildMatchIndex( IReadOnlyList projectCharacters, IReadOnlyDictionary> aliases) { var termsByCharacter = new Dictionary>(); foreach (var character in projectCharacters) { var terms = new HashSet(StringComparer.OrdinalIgnoreCase); AddTerm(terms, character.CharacterName); AddTerm(terms, character.ShortName); foreach (var alias in aliases.GetValueOrDefault(character.CharacterID) ?? []) { AddTerm(terms, alias.Alias); } termsByCharacter[character.CharacterID] = terms; } var tokenOwners = termsByCharacter .SelectMany(pair => pair.Value.SelectMany(TokeniseName).Select(token => (Token: token, CharacterID: pair.Key))) .GroupBy(item => item.Token, StringComparer.OrdinalIgnoreCase) .ToDictionary( group => group.Key, group => group.Select(item => item.CharacterID).Distinct().Count(), StringComparer.OrdinalIgnoreCase); var index = new List(); foreach (var character in projectCharacters) { foreach (var term in termsByCharacter[character.CharacterID]) { var tokens = TokeniseName(term).ToList(); if (tokens.Count == 0) { continue; } if (tokens.Count == 1 && (GenericReferences.Contains(tokens[0]) || tokenOwners.GetValueOrDefault(tokens[0]) > 1)) { continue; } index.Add(new DeterministicCharacterMatchTerm(character.CharacterID, term, RegexForTerm(term))); if (tokens.Count > 1) { AddUniqueTokenTerm(index, character.CharacterID, tokens[0], tokenOwners); AddUniqueTokenTerm(index, character.CharacterID, tokens[^1], tokenOwners); } } } return index .GroupBy(item => (item.CharacterID, Key: item.Term.ToLowerInvariant())) .Select(group => group.First()) .OrderByDescending(item => item.Term.Length) .ThenBy(item => item.Term, StringComparer.OrdinalIgnoreCase) .ToList(); } internal static IReadOnlyList MatchScene( string? sourceText, IReadOnlyList matchIndex) { if (string.IsNullOrWhiteSpace(sourceText) || matchIndex.Count == 0) { return []; } var matches = new Dictionary(); foreach (var term in matchIndex) { var regexMatches = term.Regex.Matches(sourceText); if (regexMatches.Count == 0) { continue; } if (!matches.TryGetValue(term.CharacterID, out var existing) || regexMatches[0].Index < existing.FirstOccurrenceIndex || (regexMatches[0].Index == existing.FirstOccurrenceIndex && term.Term.Length > existing.MatchedText.Length)) { matches[term.CharacterID] = new DeterministicSceneCharacterMatch( term.CharacterID, term.Term, regexMatches.Count, regexMatches[0].Index); } } return matches.Values.ToList(); } private static void AddUniqueTokenTerm( List index, int characterId, string token, IReadOnlyDictionary tokenOwners) { if (!GenericReferences.Contains(token) && tokenOwners.GetValueOrDefault(token) == 1) { index.Add(new DeterministicCharacterMatchTerm(characterId, token, RegexForTerm(token))); } } private static void AddTerm(HashSet terms, string? value) { var clean = CleanName(value); if (!string.IsNullOrWhiteSpace(clean) && !GenericReferences.Contains(clean)) { terms.Add(clean); } } private static IEnumerable TokeniseName(string value) => value.Split([' ', '-', '\''], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(CleanName) .Where(token => token.Length > 1); private static Regex RegexForTerm(string term) => new($@"(? Regex.Replace(value?.Trim() ?? string.Empty, @"\s+", " "); } public sealed record CoreImportCharacterMappingResult(int ScenesScanned, int LinksSaved); public sealed record DeterministicCharacterMatchTerm(int CharacterID, string Term, Regex Regex); public sealed record DeterministicSceneCharacterMatch(int CharacterID, string MatchedText, int OccurrenceCount, int FirstOccurrenceIndex);