PlotDirector/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs

668 lines
35 KiB
C#

using System.Text.Json;
using PlotLine.Models;
namespace PlotLine.Services;
public static class StoryIntelligenceIllustrationCompatibility
{
public static class AgeBands
{
public const string Child = "Child";
public const string YoungTeen = "YoungTeen";
public const string OlderTeen = "OlderTeen";
public const string YoungAdult = "YoungAdult";
public const string Adult = "Adult";
public const string MatureAdult = "MatureAdult";
public const string Senior = "Senior";
public const string Unknown = "Unknown";
}
public static class Presentations
{
public const string Feminine = "Feminine";
public const string Masculine = "Masculine";
public const string Androgynous = "Androgynous";
public const string Unknown = "Unknown";
}
public static class HairColours
{
public const string Black = "Black";
public const string Brown = "Brown";
public const string Blonde = "Blonde";
public const string Red = "Red";
public const string Grey = "Grey";
public const string White = "White";
public const string Bald = "Bald";
public const string Unknown = "Unknown";
}
public static class SkinTones
{
public const string Light = "Light";
public const string Medium = "Medium";
public const string Dark = "Dark";
public const string Unknown = "Unknown";
}
public static CharacterEvidenceProfile InferCharacterEvidence(string name, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
{
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var identityScopedText = $" {string.Join(". ", identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var relationshipText = $" {string.Join(". ", relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var nameText = $" {name.ToLowerInvariant()} ";
var entityText = $"{nameText} {identityText}";
var scopedText = EntityScopedText($"{nameText} {identityScopedText}", name);
var relationshipSafeText = RelationshipScopedText(relationshipText, name);
var titlePresentation = TitlePresentation(scopedText);
var relationshipPresentation = RelationshipPresentation(relationshipSafeText);
var entityPresentation = RelationshipPresentation(scopedText);
var pronounPresentation = PronounPresentation(scopedText);
var namePresentation = NamePresentation(entityText);
var presentation = FirstKnown(
titlePresentation.Value,
entityPresentation.Value,
relationshipPresentation.Value,
pronounPresentation.Value,
namePresentation.Value,
Presentations.Unknown);
var presentationConfidence = new[] { titlePresentation.Confidence, entityPresentation.Confidence, relationshipPresentation.Confidence, pronounPresentation.Confidence, namePresentation.Confidence }.Max();
var warnings = new List<string>();
var guardedPresentation = ApplyPresentationGuard(
name,
scopedText,
presentation,
presentationConfidence,
warnings);
presentation = guardedPresentation.Value;
presentationConfidence = guardedPresentation.Confidence;
var explicitAge = ExplicitAgeBand(scopedText);
if (explicitAge.Value == AgeBands.Unknown)
{
explicitAge = EntityIdentityAgeBand(name, identityText);
}
var relationshipAge = RelationshipAgeBand(name, scopedText);
var namedAge = NameAgeBand(name);
var age = FirstKnown(explicitAge.Value, relationshipAge.Value, namedAge.Value, AgeBands.Unknown);
var ageConfidence = new[] { explicitAge.Confidence, relationshipAge.Confidence, namedAge.Confidence }.Max();
var attributeText = EntityAttributeText(entityText, name);
var hairColour = HairColour(attributeText);
var skinTone = SkinTone(attributeText);
var hairLength = HairLength(attributeText);
return new(
age,
presentation,
hairColour,
skinTone,
hairLength,
ContainsAny(attributeText, "glasses", "spectacles") ? "Yes" : "Unknown",
ContainsAny(attributeText, "moustache", "mustache") ? "Moustache" : ContainsAny(attributeText, "beard", "bearded") ? "Beard" : "Unknown",
ageConfidence,
presentationConfidence,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence,
warnings);
}
public static IllustrationCharacterMetadata CharacterMetadataFrom(IllustrationLibraryItem item)
{
IllustrationGenerationSpecification? specification = null;
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(item.MetadataJson))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
{
try
{
specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
var stableCode = item.StableCode ?? string.Empty;
var isUnknownFigure = BoolMetadata(metadata, "isUnknownFigure") || stableCode.Contains("unknown-figure", StringComparison.OrdinalIgnoreCase);
var isGenericFallback = BoolMetadata(metadata, "isGenericFallback") || stableCode.Contains("fallback", StringComparison.OrdinalIgnoreCase);
var suitableForNamed = !isUnknownFigure && !isGenericFallback && !BoolMetadata(metadata, "notSuitableForNamedCharacter");
return new(
NormalizeAgeBand(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand ?? stableCode),
NormalizePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation ?? stableCode),
NormalizeHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour ?? stableCode),
NormalizeSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone ?? stableCode),
NormalizeHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength ?? stableCode),
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown"),
isUnknownFigure,
isGenericFallback,
suitableForNamed,
item.PromptTemplateVersion,
FirstMetadata(metadata, "generationSource", "source") ?? string.Empty);
}
public static IReadOnlyList<string> CharacterHardRejections(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata candidate, bool isNamedCharacter, bool alreadyAssignedToAnotherSignificantCharacter)
{
var rejected = new List<string>();
if (isNamedCharacter && candidate.IsUnknownFigure)
{
rejected.Add("named character cannot use Unknown Figure artwork");
}
if (isNamedCharacter && !candidate.IsSuitableForNamedCharacter)
{
rejected.Add("candidate is not suitable for strict named-character matching");
}
if (AgeIncompatible(evidence.AgeBand, candidate.AgeBand))
{
rejected.Add($"age hard mismatch: wanted {evidence.AgeBand}, candidate {candidate.AgeBand}");
}
if (evidence.HasStrongPresentationEvidence
&& PresentationIncompatible(evidence.Presentation, candidate.Presentation))
{
rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {candidate.Presentation}");
}
if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& KnownMismatch(evidence.HairColour, candidate.HairColour))
{
rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}");
}
if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter)
{
rejected.Add("already assigned to another significant named character");
}
return rejected;
}
public static string NormalizeAgeBand(string? value)
{
var text = (value ?? string.Empty).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
if (text.Contains("youngteen") || text.Contains("13") || text.Contains("14") || text.Contains("15") || text.Contains("fifteen")) return AgeBands.YoungTeen;
if (text.Contains("olderteen") || text.Contains("teen") || text.Contains("16") || text.Contains("17") || text.Contains("18") || text.Contains("19")) return AgeBands.OlderTeen;
if (text.Contains("child")) return AgeBands.Child;
if (text.Contains("youngadult")) return AgeBands.YoungAdult;
if (text.Contains("middleaged") || text.Contains("matureadult") || text.Contains("parent") || text.Contains("mother") || text.Contains("father") || text.Contains("aunt") || text.Contains("uncle")) return AgeBands.MatureAdult;
if (text.Contains("adult")) return AgeBands.Adult;
if (text.Contains("senior") || text.Contains("elderly") || text.Contains("older") || text.Contains("grandmother") || text.Contains("grandfather")) return AgeBands.Senior;
return AgeBands.Unknown;
}
public static string NormalizePresentation(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "feminine", "female", "woman", "girl", "mother", "mum", "mrs", "miss", "ms", "aunt", "sister", "daughter", "wife", "grandmother")) return Presentations.Feminine;
if (ContainsAny(text, "masculine", "male", "man", "boy", "father", "dad", "mr", "uncle", "brother", "son", "husband", "grandfather")) return Presentations.Masculine;
if (text.Contains("androgynous", StringComparison.OrdinalIgnoreCase)) return Presentations.Androgynous;
return Presentations.Unknown;
}
public static string NormalizeHairColour(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return HairColours.Bald;
if (text.Contains("black", StringComparison.OrdinalIgnoreCase)) return HairColours.Black;
if (ContainsAny(text, "brown", "brunette", "darkbrown", "dark hair")) return HairColours.Brown;
if (ContainsAny(text, "blonde", "fair hair")) return HairColours.Blonde;
if (ContainsAny(text, "red", "auburn", "ginger")) return HairColours.Red;
if (ContainsAny(text, "grey", "gray")) return HairColours.Grey;
if (text.Contains("white", StringComparison.OrdinalIgnoreCase)) return HairColours.White;
return HairColours.Unknown;
}
public static string NormalizeSkinTone(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "deep", "dark", "black skin")) return SkinTones.Dark;
if (ContainsAny(text, "medium", "lightmedium", "olive", "brown skin")) return SkinTones.Medium;
if (ContainsAny(text, "light", "pale", "fair skin")) return SkinTones.Light;
return SkinTones.Unknown;
}
public static string NormalizeHairLength(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return "Bald";
if (text.Contains("short", StringComparison.OrdinalIgnoreCase)) return "Short";
if (text.Contains("long", StringComparison.OrdinalIgnoreCase)) return "Long";
if (text.Contains("medium", StringComparison.OrdinalIgnoreCase) || text.Contains("shoulder", StringComparison.OrdinalIgnoreCase)) return "Medium";
return "Unknown";
}
public static string LocationType(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell";
if (ContainsAny(text, "front door", "porch", "entrance", "house", "exterior")) return "HouseExterior";
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
if (ContainsAny(text, "road", "street", "lane", "drive", "close", "avenue", "boulevard", "motorway", "bridge", "roundabout", "footpath", "track")) return "Road";
if (ContainsAny(text, "car park", "parking")) return "CarPark";
if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
if (text.Contains("hospital", StringComparison.OrdinalIgnoreCase)) return "Hospital";
if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior";
return "UnknownInterior";
}
public static string AssetType(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
var padded = $" {text} ";
if (text.Contains("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance";
if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher";
if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag";
if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle";
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
if (ContainsAny(text, "driving licence", "driver licence", "provisional licence", "license", "licence", "pass certificate", "certificate", "paperwork")) return "Document";
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback";
if (ContainsAny(padded, " family car ", " estate car ", " saloon ")) return "FamilyCar";
if (ContainsAny(padded, " sports car ", " tr6 ", " roadster ")) return "SportsCar";
if (ContainsAny(padded, " van ", " transit ")) return "Van";
if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car";
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
if (ContainsAny(text, "parcel", "package")) return "Parcel";
if (ContainsAny(text, "blouse", "skirt", "jumper", "jumpers", "sunglasses", "stockings")) return "Clothing";
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter";
if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook";
if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book";
if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone";
if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys";
if (ContainsAny(text, "tr6")) return "Car";
if (ContainsAny(text, "photo", "photograph")) return "Photograph";
if (ContainsAny(text, "document", "paper")) return "Document";
return "UnknownObject";
}
public static bool LocationCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|| (requestedType == "Bathroom" && candidateType == "Bathroom");
public static bool AssetCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
public static bool IsGroupEntity(string? value)
{
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
return clean.EndsWith('s')
&& (clean.Contains("social worker", StringComparison.Ordinal)
|| clean.Contains("neighbour", StringComparison.Ordinal)
|| clean.Contains("neighbor", StringComparison.Ordinal)
|| clean.Contains("police officer", StringComparison.Ordinal)
|| clean.Contains("student", StringComparison.Ordinal)
|| clean.Contains("examiner", StringComparison.Ordinal)
|| clean.Contains("uniformed", StringComparison.Ordinal)
|| clean.Contains("responder", StringComparison.Ordinal))
|| clean is "crowd" or "uniformed people" or "police" or "social services";
}
public static string PresentationGuardWarning(string name, string attemptedPresentation, string correctedPresentation)
=> $"WARNING: Entity evidence guard corrected {name} presentation from {attemptedPresentation} to {correctedPresentation}; title/name hard evidence overrode leaked or unrelated evidence.";
private static EvidenceSignal ExplicitAgeBand(string text)
{
if (ContainsAny(text, "15", "fifteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "13", "thirteen", "14", "fourteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "16", "sixteen", "17", "seventeen", "18", "eighteen", "19", "nineteen")) return new(AgeBands.OlderTeen, 0.98m);
if (ContainsAny(text, "toddler", "baby", "infant", "newborn")) return new(AgeBands.Child, 0.98m);
if (ContainsAny(text, "child", "young child", "schoolboy", "schoolgirl", "ten-year-old", "ten year old", "aged 10", "aged 11", "aged 12")) return new(AgeBands.Child, 0.9m);
if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m);
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m);
if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m);
if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager")) return new(AgeBands.Adult, 0.88m);
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal EntityIdentityAgeBand(string name, string identityText)
{
var cleanName = (name ?? string.Empty).Trim().ToLowerInvariant();
if (ContainsAny(cleanName, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "grandmother", "grandfather"))
{
return EvidenceSignal.Unknown;
}
return ExplicitAgeBand(identityText);
}
private static EvidenceSignal RelationshipAgeBand(string name, string text)
{
if (ContainsAny(text, "toddler", "baby", "newborn")) return new(AgeBands.Child, 0.96m);
var identity = $"{name} {text}";
if (ContainsAny(identity, "mother", " mum", "father", " dad", "parent", "aunt", "uncle")
&& !PossessiveOfOtherIdentity(name, text))
{
return new(AgeBands.MatureAdult, 0.82m);
}
if (ContainsAny(identity, "teacher", "driving examiner", "detective", "police officer", "nurse", "receptionist", "solicitor", "landlord", "manager", "professor", "doctor")
&& !PossessiveOfOtherIdentity(name, text))
{
return new(AgeBands.Adult, 0.82m);
}
if (ContainsAny(text, "grandmother", "grandfather")) return new(AgeBands.Senior, 0.82m);
if (ContainsAny(text, "daughter", "son", "sister", "brother")) return new(AgeBands.Unknown, 0.3m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NameAgeBand(string name)
{
return EvidenceSignal.Unknown;
}
private static EvidenceSignal TitlePresentation(string text)
{
if (ContainsAny(text, " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " lady ")) return new(Presentations.Feminine, 0.96m);
if (ContainsAny(text, " mr ", " mr. ", " sir ")) return new(Presentations.Masculine, 0.96m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal RelationshipPresentation(string text)
{
if (ContainsAny(text, "female", "mother", " mum", "mummy", "aunt", "sister", "daughter", "wife", "widow", "grandmother", " niece", "woman", "girl", "lady", "queen")) return new(Presentations.Feminine, 0.92m);
if (ContainsAny(text, "male", "gentleman", "father", " dad", "daddy", "uncle", "brother", "son", "husband", "widower", "grandfather", " nephew", "man", "boy", "lad", "bloke", "chap", "king")) return new(Presentations.Masculine, 0.92m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal PronounPresentation(string text)
{
if (ContainsAny(text, " she ", " her ", " hers ", " herself ")) return new(Presentations.Feminine, 0.86m);
if (ContainsAny(text, " he ", " him ", " his ", " himself ")) return new(Presentations.Masculine, 0.86m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NamePresentation(string text)
{
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " rebecca ", " annie ", " mary ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m);
if (ContainsAny(text, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " george ")) return new(Presentations.Masculine, 0.82m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal ApplyPresentationGuard(string name, string scopedText, string presentation, decimal confidence, ICollection<string> warnings)
{
var nameSignal = NameOrTitlePresentation(name);
if (nameSignal.Value == Presentations.Unknown)
{
return new(presentation, confidence);
}
if (presentation == Presentations.Unknown || presentation == nameSignal.Value)
{
return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence));
}
if (HasExplicitContradictoryPresentation(name, scopedText, presentation))
{
warnings.Add($"WARNING: {name} has explicit contradictory presentation evidence: {presentation} conflicts with {nameSignal.Value} name/title evidence.");
return new(presentation, confidence);
}
warnings.Add(PresentationGuardWarning(name, presentation, nameSignal.Value));
return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence));
}
private static EvidenceSignal NameOrTitlePresentation(string name)
{
var clean = CleanWords(name);
if (clean.Length == 0)
{
return EvidenceSignal.Unknown;
}
if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male")
|| clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george")
{
return new(Presentations.Masculine, 0.98m);
}
if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female")
|| clean is "beth" or "maggie" or "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen")
{
return new(Presentations.Feminine, 0.98m);
}
return EvidenceSignal.Unknown;
}
private static bool HasExplicitContradictoryPresentation(string name, string scopedText, string attemptedPresentation)
{
var clean = CleanWords(name);
if (clean.Length == 0 || string.IsNullOrWhiteSpace(scopedText))
{
return false;
}
var text = CleanWords(scopedText);
var contradictoryTerms = attemptedPresentation == Presentations.Feminine
? new[] { "female", "woman", "girl" }
: attemptedPresentation == Presentations.Masculine
? new[] { "male", "man", "boy" }
: [];
return contradictoryTerms.Any(term =>
text.Contains($"{clean} is {term}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{clean} was {term}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{clean} as {term}", StringComparison.OrdinalIgnoreCase));
}
private static string HairColour(string text) => NormalizeHairColour(text);
private static string SkinTone(string text) => NormalizeSkinTone(text);
private static string HairLength(string text) => NormalizeHairLength(text);
private static bool AgeIncompatible(string evidence, string candidate)
=> (evidence, candidate) switch
{
(AgeBands.Child, not AgeBands.Child) => true,
(AgeBands.YoungTeen, AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.OlderTeen, AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.Adult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.MatureAdult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.Senior, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen or AgeBands.YoungAdult) => true,
_ => false
};
private static bool PresentationIncompatible(string evidence, string candidate)
=> (evidence, candidate) is (Presentations.Feminine, Presentations.Masculine) or (Presentations.Masculine, Presentations.Feminine);
private static bool ExplicitKnown(string value, decimal confidence)
=> !string.IsNullOrWhiteSpace(value) && value != "Unknown" && confidence >= 0.85m;
private static bool KnownMismatch(string evidence, string candidate)
=> !string.Equals(evidence, "Unknown", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate, "Unknown", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(evidence, candidate, StringComparison.OrdinalIgnoreCase);
private static string FirstKnown(params string[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && !string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase)) ?? "Unknown";
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static bool BoolMetadata(Dictionary<string, string>? metadata, string key)
=> bool.TryParse(metadata?.GetValueOrDefault(key), out var value) && value;
private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static bool StartsWithAny(string text, params string[] prefixes)
=> prefixes.Any(prefix => text.Equals(prefix, StringComparison.OrdinalIgnoreCase) || text.StartsWith(prefix + " ", StringComparison.OrdinalIgnoreCase));
private static string CleanWords(string value)
{
var chars = value.Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray();
return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private static string EntityAttributeText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var scoped = sentences
.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase))
.Where(sentence => AttributeNearEntity(sentence, cleanName))
.ToList();
return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} ";
}
private static string EntityScopedText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var possessiveAscii = $"{cleanName}'s";
var possessiveCurly = $"{cleanName}\u2019s";
var scoped = sentences.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase)).ToList();
scoped = scoped
.Where(sentence => !sentence.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
&& !sentence.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
.ToList();
return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} ";
}
private static string RelationshipScopedText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var possessiveAscii = $"{cleanName}'s";
var possessiveCurly = $"{cleanName}\u2019s";
if (text.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
|| text.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
{
return string.Empty;
}
var fragments = text.Split(['.', '!', '?', ';', ',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(fragment => !fragment.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
&& !fragment.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
.ToList();
return fragments.Count == 0 ? string.Empty : $" {string.Join(' ', fragments)} ";
}
private static bool AttributeNearEntity(string sentence, string cleanName)
{
var lower = sentence.ToLowerInvariant();
var entityIndex = lower.IndexOf(cleanName, StringComparison.Ordinal);
if (entityIndex < 0)
{
return false;
}
var attributeIndex = new[]
{
lower.IndexOf("hair", StringComparison.Ordinal),
lower.IndexOf("skin", StringComparison.Ordinal),
lower.IndexOf("glasses", StringComparison.Ordinal),
lower.IndexOf("spectacles", StringComparison.Ordinal),
lower.IndexOf("beard", StringComparison.Ordinal),
lower.IndexOf("moustache", StringComparison.Ordinal),
lower.IndexOf("mustache", StringComparison.Ordinal)
}
.Where(index => index >= 0)
.DefaultIfEmpty(-1)
.Min();
return attributeIndex >= 0 && Math.Abs(attributeIndex - entityIndex) <= 48;
}
private static bool PossessiveOfOtherIdentity(string name, string text)
{
var cleanName = (name ?? string.Empty).Trim().ToLowerInvariant();
if (cleanName.Contains("mother", StringComparison.Ordinal)
|| cleanName.Contains("mum", StringComparison.Ordinal)
|| cleanName.Contains("father", StringComparison.Ordinal)
|| cleanName.Contains("dad", StringComparison.Ordinal)
|| cleanName.Contains("aunt", StringComparison.Ordinal)
|| cleanName.Contains("uncle", StringComparison.Ordinal)
|| cleanName.Contains("teacher", StringComparison.Ordinal))
{
return false;
}
return ContainsAny(text, "'s mother", "'s mum", "'s father", "'s dad", "'s aunt", "'s uncle", " her mother", " his mother", " her mum", " his dad");
}
private sealed record EvidenceSignal(string Value, decimal Confidence)
{
public static EvidenceSignal Unknown { get; } = new("Unknown", 0);
}
}
public sealed record CharacterEvidenceProfile(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
decimal AgeConfidence,
decimal PresentationConfidence,
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence,
IReadOnlyList<string> Warnings)
{
public bool HasStrongPresentationEvidence => PresentationConfidence >= 0.8m;
}
public sealed record IllustrationCharacterMetadata(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
bool IsUnknownFigure,
bool IsGenericFallback,
bool IsSuitableForNamedCharacter,
string? PromptTemplateVersion,
string GenerationSource);