using System.Text.RegularExpressions; using PlotLine.Models; namespace PlotLine.Services; public sealed record CharacterAppearanceFact( string CanonicalName, IReadOnlyList 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+(?[A-Z][A-Za-z'’-]+)\b[^.!?]{0,80}?\b(?:call me|called)\s+(?[A-Z][A-Za-z'’-]+)\b", RegexOptions.Compiled); private static readonly Regex HairRegex = new(@"\b(?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(?(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?(?i:natural)\s+)?(?(?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(?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(?(?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(?(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?(?i:natural)\s+)?(?(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)(?:ed)?\s+(?(?i:girl|woman|man|boy))\b", RegexOptions.Compiled); private static readonly Regex HairAdjectiveLeadingRegex = new(@"\b(?(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\s+(?(?i:girl|woman|man|boy|young woman|young man))\b", RegexOptions.Compiled); private static readonly Regex AgeRegex = new(@"\b(?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(?(?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 Extract(string sourceText, IEnumerable? knownCharacterNames = null, string sourceScene = "Fixture") { var facts = new List(); 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? 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 AliasMap(string sourceText, IEnumerable? knownCharacterNames = null) { var aliases = new Dictionary(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 facts, string canonical, IReadOnlyList 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 AliasesFor(string name, IReadOnlyDictionary 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)); } }