Correct relationship intelligence classification

This commit is contained in:
Nick Beckley 2026-08-29 14:14:34 +00:00
parent feed4549bf
commit 85286428c2
2 changed files with 245 additions and 31 deletions

View File

@ -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<KnowledgeState>
@ -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(

View File

@ -362,14 +362,20 @@ public sealed class StoryIntelligenceRelationshipImportService(
decimal? confidence,
IReadOnlyList<RelationshipEvent> 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<string> 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<RelationshipTypeClassification>
{
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);
}