218 lines
8.9 KiB
C#
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);
|