PlotDirector/PlotLine/Services/CharacterAppearanceExtractionService.cs

192 lines
9.6 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.RegularExpressions;
using PlotLine.Models;
namespace PlotLine.Services;
public sealed record CharacterAppearanceFact(
string CanonicalName,
IReadOnlyList<string> Aliases,
string AttributeType,
string NormalisedValue,
string EvidenceFragment,
decimal Confidence,
bool IsExplicit,
string SourceScene);
public static class CharacterAppearanceExtractionService
{
private static readonly Regex MissCallMeRegex = new(@"\bMiss\s+(?<surname>[A-Z][A-Za-z'-]+)\b[^.!?]{0,80}?\b(?:call me|called)\s+(?<first>[A-Z][A-Za-z'-]+)\b", RegexOptions.Compiled);
private static readonly Regex HairRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'-]+|Miss\s+[A-Z][A-Za-z'-]+|[A-Z][A-Za-z'-]+)\b[^.!?]{0,90}?\b(?<length>(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?<qualifier>(?i:natural)\s+)?(?<colour>(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)\b", RegexOptions.Compiled);
private static readonly Regex HairAdjectiveRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'-]+|Miss\s+[A-Z][A-Za-z'-]+|[A-Z][A-Za-z'-]+)\b[^.!?]{0,60}?\b(?<colour>(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\b", RegexOptions.Compiled);
private static readonly Regex HairLeadingRegex = new(@"\b(?<length>(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?<qualifier>(?i:natural)\s+)?(?<colour>(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)(?:ed)?\s+(?<name>(?i:girl|woman|man|boy))\b", RegexOptions.Compiled);
private static readonly Regex HairAdjectiveLeadingRegex = new(@"\b(?<colour>(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\s+(?<name>(?i:girl|woman|man|boy|young woman|young man))\b", RegexOptions.Compiled);
private static readonly Regex AgeRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'-]+|Miss\s+[A-Z][A-Za-z'-]+|[A-Z][A-Za-z'-]+)\b[^.!?]{0,90}?\b(?<age>(?i:late teenage|teenage|fifteen|sixteen|seventeen|eighteen|nineteen|young woman|young man|adult woman|adult man|middle-aged|mature|mother|mum|driving examiner|driving instructor|teacher))\b", RegexOptions.Compiled);
public static IReadOnlyList<CharacterAppearanceFact> Extract(string sourceText, IEnumerable<string>? knownCharacterNames = null, string sourceScene = "Fixture")
{
var facts = new List<CharacterAppearanceFact>();
if (string.IsNullOrWhiteSpace(sourceText))
{
return facts;
}
var aliases = AliasMap(sourceText, knownCharacterNames);
foreach (Match match in HairRegex.Matches(sourceText))
{
var rawName = match.Groups["name"].Value.Trim();
AddHairFacts(facts, CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), match, sourceScene);
}
foreach (Match match in HairAdjectiveRegex.Matches(sourceText))
{
var rawName = match.Groups["name"].Value.Trim();
AddHairFacts(facts, CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), match, sourceScene);
}
foreach (Match match in HairLeadingRegex.Matches(sourceText))
{
var descriptor = match.Groups["name"].Value.Trim();
var candidate = (knownCharacterNames ?? []).FirstOrDefault(name => sourceText.Contains(name, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(candidate))
{
AddHairFacts(facts, CanonicalName(candidate, aliases), AliasesFor(candidate, aliases), match, sourceScene);
}
else
{
AddHairFacts(facts, descriptor, [descriptor], match, sourceScene);
}
}
foreach (Match match in HairAdjectiveLeadingRegex.Matches(sourceText))
{
var descriptor = match.Groups["name"].Value.Trim();
var candidate = (knownCharacterNames ?? []).FirstOrDefault(name => sourceText.Contains(name, StringComparison.OrdinalIgnoreCase));
AddHairFacts(facts, string.IsNullOrWhiteSpace(candidate) ? descriptor : CanonicalName(candidate, aliases), string.IsNullOrWhiteSpace(candidate) ? [descriptor] : AliasesFor(candidate, aliases), match, sourceScene);
}
foreach (Match match in AgeRegex.Matches(sourceText))
{
var rawName = match.Groups["name"].Value.Trim();
if (LooksLikeLeakedRelationshipAge(rawName, match.Groups["age"].Value))
{
continue;
}
var age = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(rawName, [match.Value], []).AgeBand;
if (age != StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown)
{
facts.Add(new(CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), "AgeBand", age, ShortFragment(match.Value), 0.9m, true, sourceScene));
}
}
foreach (var (alias, canonical) in aliases)
{
facts.Add(new(canonical, AliasesFor(alias, aliases), "Alias", alias, AliasEvidence(sourceText, alias, canonical), 0.96m, true, sourceScene));
}
return facts
.GroupBy(fact => $"{fact.CanonicalName}|{fact.AttributeType}|{fact.NormalisedValue}|{fact.EvidenceFragment}", StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToList();
}
public static string CanonicalName(string name, IReadOnlyDictionary<string, string>? aliases = null)
{
var clean = (name ?? string.Empty).Trim();
if (aliases is not null && aliases.TryGetValue(Normalise(clean), out var canonical))
{
return canonical;
}
return clean;
}
public static IReadOnlyDictionary<string, string> AliasMap(string sourceText, IEnumerable<string>? knownCharacterNames = null)
{
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in MissCallMeRegex.Matches(sourceText ?? string.Empty))
{
var first = match.Groups["first"].Value.Trim();
var surname = match.Groups["surname"].Value.Trim();
var canonical = $"{first} {surname}";
aliases[Normalise(first)] = canonical;
aliases[Normalise($"Miss {surname}")] = canonical;
aliases[Normalise($"Miss {surname} ({first})")] = canonical;
}
return aliases;
}
private static void AddHairFacts(List<CharacterAppearanceFact> facts, string canonical, IReadOnlyList<string> aliases, Match match, string sourceScene)
{
var colour = StoryIntelligenceIllustrationCompatibility.NormalizeHairColour(match.Groups["colour"].Value);
if (colour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown)
{
facts.Add(new(canonical, aliases, "HairColour", colour, ShortFragment(match.Value), 0.94m, true, sourceScene));
}
var length = StoryIntelligenceIllustrationCompatibility.NormalizeHairLength(match.Groups["length"].Value);
if (length != "Unknown")
{
facts.Add(new(canonical, aliases, "HairLength", length, ShortFragment(match.Value), 0.88m, true, sourceScene));
}
}
private static IReadOnlyList<string> AliasesFor(string name, IReadOnlyDictionary<string, string> aliases)
{
var canonical = CanonicalName(name, aliases);
var list = aliases
.Where(item => string.Equals(item.Value, canonical, StringComparison.OrdinalIgnoreCase))
.Select(item => item.Key)
.Append(Normalise(name))
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return list.Count == 0 ? [name] : list;
}
private static string AliasEvidence(string sourceText, string alias, string canonical)
{
var index = sourceText.IndexOf(alias, StringComparison.OrdinalIgnoreCase);
if (index < 0)
{
index = sourceText.IndexOf(canonical.Split(' ')[0], StringComparison.OrdinalIgnoreCase);
}
if (index < 0)
{
return canonical;
}
var start = Math.Max(0, index - 60);
var length = Math.Min(sourceText.Length - start, 140);
return ShortFragment(sourceText.Substring(start, length));
}
private static string ShortFragment(string value)
=> Regex.Replace(value.Trim(), @"\s+", " ") is var clean && clean.Length > 160 ? clean[..160].TrimEnd() : clean;
private static bool LooksLikeLeakedRelationshipAge(string rawName, string rawAge)
{
var name = Normalise(rawName);
var age = Normalise(rawAge);
var relationshipAge = age is "mother" or "mum" or "father" or "dad" or "aunt" or "uncle";
if (!relationshipAge)
{
return false;
}
return name is not ("mum" or "mother" or "dad" or "father" or "aunt" or "uncle")
&& !name.StartsWith("mrs ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("miss ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("ms ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("mr ", StringComparison.OrdinalIgnoreCase);
}
private static string Normalise(string? value)
{
var chars = (value ?? string.Empty).Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray();
return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
}