170 lines
8.8 KiB
C#
170 lines
8.8 KiB
C#
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();
|
||
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 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));
|
||
}
|
||
}
|