PlotDirector/PlotLine/Services/CoreImportCharacterMappingService.cs

218 lines
8.9 KiB
C#

using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface ICoreImportCharacterMappingService
{
Task<CoreImportCharacterMappingResult> MapBookAsync(int projectId, int bookId);
}
public sealed class CoreImportCharacterMappingService(
IOnboardingBuildRepository builds,
ICharacterRepository characters,
ILogger<CoreImportCharacterMappingService> logger) : ICoreImportCharacterMappingService
{
private const string MatchSource = "CoreImportDeterministic";
private static readonly HashSet<string> 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<CoreImportCharacterMappingResult> 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<int, IReadOnlyList<CharacterAlias>>();
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<DeterministicCharacterMatchTerm> BuildMatchIndex(
IReadOnlyList<Character> projectCharacters,
IReadOnlyDictionary<int, IReadOnlyList<CharacterAlias>> aliases)
{
var termsByCharacter = new Dictionary<int, HashSet<string>>();
foreach (var character in projectCharacters)
{
var terms = new HashSet<string>(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<DeterministicCharacterMatchTerm>();
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<DeterministicSceneCharacterMatch> MatchScene(
string? sourceText,
IReadOnlyList<DeterministicCharacterMatchTerm> matchIndex)
{
if (string.IsNullOrWhiteSpace(sourceText) || matchIndex.Count == 0)
{
return [];
}
var matches = new Dictionary<int, DeterministicSceneCharacterMatch>();
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<DeterministicCharacterMatchTerm> index,
int characterId,
string token,
IReadOnlyDictionary<string, int> tokenOwners)
{
if (!GenericReferences.Contains(token) && tokenOwners.GetValueOrDefault(token) == 1)
{
index.Add(new DeterministicCharacterMatchTerm(characterId, token, RegexForTerm(token)));
}
}
private static void AddTerm(HashSet<string> terms, string? value)
{
var clean = CleanName(value);
if (!string.IsNullOrWhiteSpace(clean) && !GenericReferences.Contains(clean))
{
terms.Add(clean);
}
}
private static IEnumerable<string> TokeniseName(string value)
=> value.Split([' ', '-', '\''], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(CleanName)
.Where(token => token.Length > 1);
private static Regex RegexForTerm(string term)
=> new($@"(?<![\p{{L}}\p{{N}}]){Regex.Escape(term)}(?![\p{{L}}\p{{N}}])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static string CleanName(string? value)
=> 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);