From 85286428c27d6fb0f6a11ba23450b05e34124632 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 29 Aug 2026 14:14:34 +0000 Subject: [PATCH] Correct relationship intelligence classification --- PlotLine.Tests/Program.cs | 34 +++ ...ryIntelligenceRelationshipImportService.cs | 242 +++++++++++++++--- 2 files changed, 245 insertions(+), 31 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 282aa17..7bd6522 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -55,6 +55,7 @@ var tests = new (string Name, Action Test)[] ("Relationship progression keeps chronological state", RelationshipProgressionKeepsChronologicalState), ("Relationship observations do not create event spam", RelationshipObservationsDoNotCreateEventSpam), ("Relationship review orders by first story appearance", RelationshipReviewOrdersByFirstStoryAppearance), + ("Relationship evidence priority preserves explicit semantics", RelationshipEvidencePriorityPreservesExplicitSemantics), ("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates), ("Knowledge recipient resolves narrator through POV", KnowledgeRecipientResolvesNarratorThroughPov), ("Knowledge identity reveal preserves subjective timing", KnowledgeIdentityRevealPreservesSubjectiveTiming), @@ -2209,6 +2210,28 @@ static void RelationshipReviewOrdersByFirstStoryAppearance() Assert(!service.Contains(".OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())", StringComparison.Ordinal), "Relationship review should not prioritise frequent relationships ahead of earlier first appearances."); } +static void RelationshipEvidencePriorityPreservesExplicitSemantics() +{ + Assert(RelationshipTypeFromEvidence("dependence", "My brother helped with the machine.", null, "Nora", "Elias") == "Sibling", "Explicit brother evidence should become Sibling."); + Assert(RelationshipTypeFromEvidence("affection", "One pile was just me and Mum, my entire childhood.", null, "Nora", "Nora's mother") == "Parent", "Explicit parent evidence should become Parent."); + Assert(RelationshipTypeFromEvidence("affection", "Iris is my best friend; it has been that way since school.", null, "Nora", "Iris") == "Friend", "Explicit best-friend evidence should become Friend."); + Assert(RelationshipTypeFromEvidence("dependence", "Nora goes next door to Mrs Alder and asks for help.", null, "Nora", "Mrs Alder") == "Neighbour", "Next-door context should become Neighbour."); + Assert(RelationshipTypeFromEvidence("obligation", "They wait together.", "Nora meets coworkers Jae and Priya at the tram stop.", "Nora", "Jae") == "Professional", "Scene coworker context should become Professional."); + Assert(RelationshipTypeFromEvidence("trust", "Dr Vale reassures Mira during the assessment.", "Dr Vale is Mira's examiner.", "Mira", "Dr Vale") == "Educational", "Generic examiner/candidate context should become Educational."); + Assert(RelationshipTypeFromEvidence("affection", "Jon looks Mira up and down and flirts.", null, "Jon", "Mira") != "Romantic", "One-sided attraction/flirtation should not establish Romantic."); + Assert(RelationshipTypeFromEvidence("conflict", "Two siblings argue and insult one another.", null, "Mira", "Talia") == "Sibling", "Sibling evidence should outrank conflict."); + Assert(RelationshipTypeFromEvidence("conflict", "Mira attacks Oren after he harms her friend.", null, "Mira", "Oren") == "Rival", "One-off adversarial interaction should stay hostile/adversarial, not friendly or romantic."); + Assert(RelationshipTypeFromEvidence("dependence", "Mary arrives to take them to the theatre and Nora gets into Mary's car.", null, "Nora", "Mary") == "Unknown", "One incidental lift should not become Ally."); + Assert(RelationshipTypeFromEvidence("secrecy", "Nora searches for her sister under another name.", null, "Nora", "Mira") == "Sibling", "Identity timing belongs to Knowledge; objective sibling evidence should remain Sibling."); + Assert(RelationshipTypeFromEvidence("family", "Nora's adoptive mother speaks to Nora.", null, "Nora", "Nora's adoptive mother") == "Parent", "Adoptive family evidence should classify the direct relationship without transitive assumptions."); + Assert(RelationshipTypeFromEvidence("admiration", "Nora regards Priya as like an older sister after the rescue.", null, "Nora", "Priya") != "Sibling", "Figurative sibling language should not create an objective Sibling relationship."); + Assert(RelationshipTypeFromEvidence("information", "Pat addresses Nora directly about looking for her sister.", null, "Pat", "Nora") != "Sibling", "Third-party kinship discussion should not make the speakers siblings."); + Assert(RelationshipTypeFromEvidence("conflict", "Oren kisses Mira before assaulting her.", null, "Oren", "Mira") == "Rival", "Assault evidence should outrank kiss wording and remain adversarial."); + Assert(RelationshipTypeFromEvidence("family", "Mira's mum tells the group to look after Mira.", null, "Mira", "Mira's mum") == "Parent", "Owned mum/mother variants should classify as Parent."); + Assert(RelationshipTypeFromEvidence("family", "Mira's mother tells the group to look after Mira.", null, "Mira", "Mira’s mum") == "Parent", "Curly apostrophe owned kinship names should classify as Parent."); + Assert(RelationshipTypeFromEvidence("affection", "Nora likes Alison and sees her as like an older sister.", "Alison is a nurse who recently moved back to town.", "Nora", "Alison") != "Medical", "Occupation-only context should not create a Medical relationship."); +} + static void KnowledgeSignalsMapToExistingStates() { var states = new List @@ -2312,6 +2335,17 @@ static string RelationshipTypeFromSignal(string signal, string? evidence = null) return (string)method!.Invoke(null, [signal, evidence])!; } +static string RelationshipTypeFromEvidence(string signal, string? evidence, string? sceneContext, string characterAName, string characterBName) +{ + var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod( + "RelationshipTypeFromEvidence", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "RelationshipTypeFromEvidence was not found."); + var classification = method!.Invoke(null, [signal, evidence, sceneContext, characterAName, characterBName]); + Assert(classification is not null, "RelationshipTypeFromEvidence returned null."); + return classification.GetType().GetProperty("Type")?.GetValue(classification) as string ?? string.Empty; +} + static string ResolveContextualAssetName(string name, string? ownerOrHolder, Scene scene) { var method = typeof(StoryIntelligenceAssetImportService).GetMethod( diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index b90c225..6c1a262 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -362,14 +362,20 @@ public sealed class StoryIntelligenceRelationshipImportService( decimal? confidence, IReadOnlyList existingEvents) { - var characterA = characterIndex.Find(characterAName); - var characterB = characterIndex.Find(characterBName); + var characterA = characterIndex.Find(characterAName, importedScene); + var characterB = characterIndex.Find(characterBName, importedScene); if (characterA is null || characterB is null || characterA.CharacterID == characterB.CharacterID) { return; } var key = PairKey(characterA.CharacterID, characterB.CharacterID); + var classification = RelationshipTypeFromEvidence( + relationshipSignal, + evidence, + importedScene.StructuralSummary, + characterA.CharacterName, + characterB.CharacterName); if (!groups.TryGetValue(key, out var candidate)) { existingByPair.TryGetValue(key, out var existingMatch); @@ -379,15 +385,18 @@ public sealed class StoryIntelligenceRelationshipImportService( characterA.CharacterName, characterB.CharacterID, characterB.CharacterName, - RelationshipTypeFromSignal(relationshipSignal, evidence), + classification.Type, existingMatch?.CharacterRelationshipID, existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}"); + candidate.RelationshipTypeClassification = classification; groups[key] = candidate; } - if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m)) + if (classification.CompareTo(candidate.RelationshipTypeClassification) > 0 + || classification.CompareTo(candidate.RelationshipTypeClassification) == 0 && (confidence ?? 0m) >= (candidate.Confidence ?? 0m)) { - candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal, evidence); + candidate.RelationshipType = classification.Type; + candidate.RelationshipTypeClassification = classification; } candidate.Confidence = Max(candidate.Confidence, confidence); @@ -519,64 +528,109 @@ public sealed class StoryIntelligenceRelationshipImportService( } private static string RelationshipTypeFromSignal(string? signal, string? evidence = null) + => RelationshipTypeFromEvidence(signal, evidence, null, null, null).Type; + + private static RelationshipTypeClassification RelationshipTypeFromEvidence( + string? signal, + string? evidence, + string? sceneContext, + string? characterAName, + string? characterBName) { - var value = NormaliseForMatching($"{signal} {evidence}"); - if (ContainsAny(value, "mother", "father", "sister", "brother", "parent", "daughter", "son", " child ", "aunt", "uncle", "cousin", "family", "sibling", "parent and child")) + var relationshipText = NormaliseForMatching($"{evidence} {characterAName} {characterBName}"); + var explicitText = NormaliseForMatching($"{evidence} {sceneContext} {characterAName} {characterBName}"); + var allText = NormaliseForMatching($"{signal} {evidence} {sceneContext} {characterAName} {characterBName}"); + if (ContainsAny(relationshipText, "best friend", "close friend", "friend since", "childhood friend")) { - return "Family"; + return new("Friend", 500); } - if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like", "attraction", "flirt", "kiss", "proposal", "engaged")) + if (HasDirectSiblingEvidence(evidence, characterAName, characterBName)) { - return "Romantic"; + return new("Sibling", 490); } - if (ContainsAny(value, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor")) + if (HasDirectParentEvidence(evidence, characterAName, characterBName)) { - return "Educational"; + return new("Parent", 480); } - if (ContainsAny(value, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest")) + if (ContainsAny(explicitText, "neighbour", "neighbor", "next door")) { - return "Authority"; + return new("Neighbour", 470); } - if (ContainsAny(value, "criminal", "accomplice", "offender", "victim", "gang", "conspirator")) + if (ContainsAny(explicitText, "coworker", "co worker", "co-worker", "colleague", "workmate", "coworkers", "co workers", "co-workers", "colleagues")) { - return "Criminal"; + return new("Professional", 460); } - if (ContainsAny(value, "doctor", "patient", "nurse", "therapist", "medical")) + if (ContainsAny(explicitText, "examiner", "candidate", "instructor", "learner", "teacher", "student", "mentor", "pupil", "tutor")) { - return "Medical"; + return new("Educational", 455); } - if (ContainsAny(value, "neighbour", "neighbor")) + if (ContainsAny(relationshipText, "aunt", "uncle", "cousin", "family", "like a sister", "like a brother")) { - return "Neighbour"; + return new("Family", 420); } - if (ContainsAny(value, "colleague", "coworker", "co worker", "boss", "employee", "manager", "client", "professional", "work relationship")) + if (ContainsAny(allText, "rival", "enemy", "adversarial", "conflict", "mistrust", "antagonist", "competitor", "confront", "attack", "attacks", "assault", "assaults", "insult", "anger", "hostile")) { - return "Professional"; + return new("Rival", 370); } - if (ContainsAny(value, "friend", "friendship", "best friend", "close friend", "childhood friend", "trust between friends", "affection between friends", "trust", "familiarity", "comfortable together", "socialise", "socialize", "spend time together", "spends time together", "hug", "hugs", "gift", "gifts", "present", "presents")) + if (ContainsAny(allText, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like", "kiss", "proposal", "engaged", "couple")) { - return "Friend"; + return new("Romantic", 360); } - if (ContainsAny(value, "rival", "enemy", "adversarial", "conflict", "mistrust", "antagonist", "competitor")) + if (ContainsAny(allText, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor", "examiner", "candidate")) { - return "Rival"; + return new("Educational", 350); } - if (ContainsAny(value, "ally", "protector", "supporter", "confidant", "dependence", "cooperation")) + if (ContainsAny(allText, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest")) { - return "Ally"; + return new("Authority", 340); } - return "Unknown"; + if (ContainsAny(allText, "criminal", "accomplice", "offender", "victim", "gang", "conspirator")) + { + return new("Criminal", 330); + } + + if (ContainsAny(relationshipText, "doctor patient", "patient doctor", "nurse patient", "patient nurse", "therapist patient", "patient therapist", "treats", "treated by", "medical care")) + { + return new("Medical", 320); + } + + if (ContainsAny(allText, "neighbour", "neighbor")) + { + return new("Neighbour", 310); + } + + if (ContainsAny(allText, "colleague", "coworker", "co worker", "boss", "employee", "manager", "client", "professional", "work relationship")) + { + return new("Professional", 300); + } + + if (ContainsAny(NormaliseForMatching(signal), "friend", "friendship")) + { + return new("Friend", 290); + } + + if (ContainsAny(allText, "friendship", "trust between friends", "affection between friends", "familiarity", "comfortable together", "socialise", "socialize", "spend time together", "spends time together")) + { + return new("Friend", 270); + } + + if (ContainsAny(allText, "ally", "protector", "supporter", "confidant", "protects", "supports")) + { + return new("Ally", 250); + } + + return new("Unknown", 0); } private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance) @@ -585,6 +639,67 @@ public sealed class StoryIntelligenceRelationshipImportService( return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase)); } + private static bool HasDirectSiblingEvidence(string? evidence, string? characterAName, string? characterBName) + { + var clean = Clean(evidence); + if (string.IsNullOrWhiteSpace(clean) + || FigurativeSibling.IsMatch(clean) + || ReportedThirdPartyKinship.IsMatch(clean)) + { + return false; + } + + var normalised = NormaliseForMatching(clean); + return ContainsAny(normalised, "my brother", "my sister", "her brother", "her sister", "his brother", "his sister", "their brother", "their sister") + || ContainsAny(normalised, "two siblings", "both siblings", "the siblings") + || HasOwnedKinshipBetweenParticipants("brother", characterAName, characterBName) + || HasOwnedKinshipBetweenParticipants("sister", characterAName, characterBName) + || HasOwnedKinshipBetweenParticipants("sibling", characterAName, characterBName); + } + + private static bool HasDirectParentEvidence(string? evidence, string? characterAName, string? characterBName) + { + var normalised = NormaliseForMatching(evidence); + return ContainsAny(normalised, "my mother", "my mum", "my mom", "my father", "my dad", "me and mum", "me and mother", "me and dad", "me and father") + || HasOwnedKinshipBetweenParticipants("mother", characterAName, characterBName) + || HasOwnedKinshipBetweenParticipants("father", characterAName, characterBName) + || HasOwnedKinshipBetweenParticipants("mum", characterAName, characterBName) + || HasOwnedKinshipBetweenParticipants("mom", characterAName, characterBName) + || HasOwnedKinshipBetweenParticipants("dad", characterAName, characterBName); + } + + private static bool HasOwnedKinshipBetweenParticipants(string kinship, string? characterAName, string? characterBName) + => IsOwnedKinshipFor(characterAName, characterBName, kinship) + || IsOwnedKinshipFor(characterBName, characterAName, kinship); + + private static bool IsOwnedKinshipFor(string? possibleKinshipCharacter, string? possibleOwnerCharacter, string kinship) + { + var owner = Clean(possibleOwnerCharacter); + if (string.IsNullOrWhiteSpace(owner)) + { + return false; + } + + var identityName = KinshipIdentityName(kinship); + var expected = NormaliseForMatching($"{owner}'s {identityName}"); + var actual = NormaliseForMatching(possibleKinshipCharacter); + if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string[] equivalentKinshipNames = identityName switch + { + "mother" => ["mother", "mum", "mom"], + "father" => ["father", "dad"], + var value => [value] + }; + var qualified = equivalentKinshipNames.Select(name => NormaliseForMatching($"{owner}'s {name}")) + .Concat(new[] { "adoptive", "biological", "step", "foster" } + .SelectMany(qualifier => equivalentKinshipNames.Select(name => NormaliseForMatching($"{owner}'s {qualifier} {name}")))); + return qualified.Any(value => string.Equals(actual, value, StringComparison.OrdinalIgnoreCase)); + } + private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) StoryOrder(RelationshipAppearanceImport appearance) => (appearance.ChapterNumber, appearance.SceneNumber, appearance.SortOrder, appearance.SceneID); @@ -728,6 +843,12 @@ public sealed class StoryIntelligenceRelationshipImportService( private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled); private static readonly Regex NonWord = new(@"[^\p{L}\p{N}]+", RegexOptions.Compiled); + private static readonly Regex FigurativeSibling = new(@"\blike\s+(?:a|an)?\s*(?:older|younger|big|little)?\s*(?:sister|brother)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex ReportedThirdPartyKinship = new(@"\b(?:about|for|of)\s+(?:looking|searching|look|search|find|finding)\s+for\s+(?:his|her|their|my)\s+(?:sister|brother|mother|father|mum|mom|dad)\b|\babout\s+(?:his|her|their|my)\s+(?:sister|brother|mother|father|mum|mom|dad)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly HashSet StandaloneKinshipReferences = new(StringComparer.OrdinalIgnoreCase) + { + "mum", "mom", "mother", "dad", "father", "parent", "parents", "brother", "sister", "sibling", "aunt", "uncle" + }; private static readonly string[] RelationshipSignalWords = [ @@ -763,6 +884,7 @@ public sealed class StoryIntelligenceRelationshipImportService( public int CharacterBID { get; } = characterBId; public string CharacterBName { get; } = characterBName; public string RelationshipType { get; set; } = relationshipType; + public RelationshipTypeClassification RelationshipTypeClassification { get; set; } = new("Unknown", 0); public int? ExistingRelationshipID { get; } = existingRelationshipId; public string? ExistingRelationshipLabel { get; } = existingRelationshipLabel; public decimal? Confidence { get; set; } @@ -799,9 +921,67 @@ public sealed class StoryIntelligenceRelationshipImportService( byName[key] = new CharacterMatch(characterId, characterName); } - public CharacterMatch? Find(string? name) - => byName.TryGetValue(Clean(name), out var match) ? match : null; + public CharacterMatch? Find(string? name, Scene scene) + { + var clean = Clean(name); + if (string.IsNullOrWhiteSpace(clean)) + { + return null; + } + + if (IsStandaloneKinshipReference(clean)) + { + var pov = Clean(scene.POVCharacterName ?? scene.POVSuggestedName); + if (!string.IsNullOrWhiteSpace(pov)) + { + var ownedReference = $"{pov}'s {KinshipIdentityName(clean)}"; + if (byName.TryGetValue(ownedReference, out var localMatch)) + { + return localMatch; + } + } + + return null; + } + + return byName.TryGetValue(clean, out var match) ? match : null; + } } + private static bool IsStandaloneKinshipReference(string name) + => StandaloneKinshipReferences.Contains(Normalise(name)); + + private static string KinshipIdentityName(string name) + => Normalise(name) switch + { + "mum" or "mom" => "mother", + "dad" => "father", + var value => value + }; + + private sealed record RelationshipTypeClassification(string Type, int Score) : IComparable + { + public int CompareTo(RelationshipTypeClassification? other) + { + if (other is null) + { + return 1; + } + + var score = Score.CompareTo(other.Score); + return score != 0 ? score : Specificity(Type).CompareTo(Specificity(other.Type)); + } + } + + private static int Specificity(string type) + => type switch + { + "Sibling" or "Parent" or "Child" or "Adoptive Parent" or "Biological Parent" or "Spouse" => 5, + "Neighbour" or "Educational" or "Professional" or "Medical" or "Authority" => 4, + "Friend" or "Romantic" or "Criminal" or "Rival" => 3, + "Family" or "Ally" => 2, + _ => 0 + }; + private sealed record CharacterMatch(int CharacterID, string CharacterName); }