PlotDirector/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs

916 lines
47 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceRelationshipImportService
{
Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form, bool completeReview);
}
public sealed class StoryIntelligenceRelationshipImportService(
IStoryIntelligenceResultRepository runs,
ICharacterRepository characters,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceRelationshipImportService> logger) : IStoryIntelligenceRelationshipImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
if (batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null)
{
return new StoryIntelligenceRelationshipReviewViewModel
{
HasCommittedScenes = true,
CanImport = false,
IsComplete = true,
RelationshipTypeOptions = [],
Candidates = []
};
}
var data = await BuildCandidateDataAsync(batch);
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes);
var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase);
var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceRelationshipReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
RelationshipTypeOptions = typeOptions,
RelationshipStateOptions = lookupData.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{
Key = candidate.Key,
ReviewRulesVersion = 3,
ProposalKind = candidate.ProposalKind,
RelationshipState = candidate.RelationshipState,
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipState),
IsReciprocal = candidate.IsReciprocal,
IsKnownToOtherCharacter = candidate.IsKnownToOtherCharacter,
Intensity = candidate.Intensity,
Development = candidate.Development,
NeedsDirectionReview = candidate.NeedsDirectionReview,
FirstChapterNumber = CandidateFirstStoryOrder(candidate).ChapterNumber,
FirstSceneNumber = CandidateFirstStoryOrder(candidate).SceneNumber,
FirstSceneID = CandidateFirstStoryOrder(candidate).SceneID,
CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName,
RelationshipType = candidate.RelationshipType,
RelationshipTypeID = ResolveRelationshipTypeID(lookupData.RelationshipTypes, selectedTypeIds.GetValueOrDefault(candidate.Key), candidate.RelationshipType),
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
FirstAppearance = candidate.FirstAppearanceNote,
LastAppearance = candidate.LastAppearanceNote,
ExampleScene = candidate.ExampleScene,
ExampleContext = candidate.ExampleContext,
ExistingRelationshipID = candidate.ExistingRelationshipID,
ExistingRelationshipLabel = candidate.ExistingRelationshipLabel
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form, bool completeReview)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing relationships." };
}
var choices = form.Relationships;
var candidates = data.Candidates.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
var lookups = await characters.GetLookupsAsync(batch.ProjectID);
// Validate the whole page before making any canonical writes.
if (choices.Select(x => x.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != choices.Count
|| choices.Any(x => !candidates.ContainsKey(x.Key)
|| x.Action is not (StoryIntelligenceRelationshipImportActions.CreateNew or StoryIntelligenceRelationshipImportActions.LinkExisting or StoryIntelligenceRelationshipImportActions.Ignore)
|| (x.Action != StoryIntelligenceRelationshipImportActions.Ignore
&& (!lookups.RelationshipTypes.Any(t => t.RelationshipTypeID == x.RelationshipTypeID)
|| !lookups.RelationshipStates.Any(t => t.RelationshipStateID == x.RelationshipStateID)
|| x.Intensity is < 1 or > 10
|| string.IsNullOrWhiteSpace(x.Development)))))
return new() { Success = false, Message = "The proposals changed or a value is invalid. Reload review and check the state, type and description before saving." };
var accepted = 0;
var ignored = 0;
foreach (var choice in choices)
{
choice.AcceptedRelationshipID = null;
var candidate = candidates[choice.Key];
if (choice.Action == StoryIntelligenceRelationshipImportActions.Ignore)
{
ignored++;
AddDecision(batch, candidate.Key, choice.Action, null, RelationshipLabel(candidate), false);
continue;
}
var appearance = candidate.Appearances.Single();
var id = await characters.AcceptRelationshipDevelopmentAsync(new RelationshipDevelopmentAcceptance
{
ProjectID = batch.ProjectID, BookID = batch.BookID, SceneID = appearance.SceneID,
SourceReviewKey = candidate.Key,
CharacterAID = choice.ReverseDirection ? candidate.CharacterBID : candidate.CharacterAID,
CharacterBID = choice.ReverseDirection ? candidate.CharacterAID : candidate.CharacterBID,
RelationshipTypeID = choice.RelationshipTypeID!.Value,
RelationshipStateID = choice.RelationshipStateID!.Value,
IsPermanent = candidate.ProposalKind == "Factual",
IsReciprocal = choice.IsReciprocal ?? candidate.IsReciprocal,
IsKnownToOtherCharacter = choice.IsKnownToOtherCharacter ?? candidate.IsKnownToOtherCharacter,
Intensity = choice.Intensity, Description = choice.Development!.Trim()
});
choice.AcceptedRelationshipID = id;
accepted++;
AddDecision(batch, candidate.Key, choice.Action, id, RelationshipLabel(candidate), true);
}
batch.RelationshipStageComplete = completeReview && choices.Count == data.Candidates.Count;
batch.LastRelationshipImportResult = new() { RelationshipEventsCreated = accepted, RelationshipsIgnored = ignored };
logger.LogInformation("Accepted {Accepted} relationship developments and ignored {Ignored} for book {BookID}", accepted, ignored, batch.BookID);
return new() { Success = true, ScenesCreated = accepted, Message = $"Saved {accepted} scene relationship proposal(s); ignored {ignored}." };
}
private async Task<RelationshipCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID);
var existingByPair = existingRelationships;
var groups = new Dictionary<string, RelationshipCandidate>(StringComparer.OrdinalIgnoreCase);
var alreadyLinked = 0;
var hasCommittedScenes = false;
foreach (var item in batch.Items)
{
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{
continue;
}
hasCommittedScenes = true;
var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
var existingEvents = await characters.ListRelationshipEventsBySceneAsync(importedScene.SceneID);
alreadyLinked += existingEvents.Select(item => item.CharacterRelationshipID).Distinct().Count();
AddSceneRelationships(groups, characterIndex, existingByPair, importedScene, parsed, existingEvents);
}
}
// A repeated durable fact is not a new development on every mention.
var candidates = groups.Values
.GroupBy(candidate => candidate.ProposalKind == "Factual"
? $"fact:{candidate.RelationshipType}:{(candidate.IsReciprocal ? PairKey(candidate.CharacterAID, candidate.CharacterBID) : $"{candidate.CharacterAID}>{candidate.CharacterBID}")}"
: candidate.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => group.OrderBy(CandidateFirstStoryOrder).First())
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.OrderBy(CandidateFirstStoryOrder)
.ThenBy(candidate => candidate.CharacterAName)
.ThenBy(candidate => candidate.CharacterBName)
.ToList();
return new RelationshipCandidateData(hasCommittedScenes, alreadyLinked, candidates);
}
private static void AddSceneRelationships(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyList<CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<RelationshipEvent> existingEvents)
{
foreach (var relationship in parsed.Relationships ?? [])
{
AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents, relationship);
}
foreach (var observation in parsed.Observations ?? [])
{
if (!IsRelationshipObservation(observation)
|| (parsed.Relationships ?? []).Any(r =>
PairNamesMatch(r.CharacterA, r.CharacterB, observation.SubjectName, observation.ObjectName)))
{
continue;
}
AddRelationship(
groups,
characterIndex,
existingByPair,
importedScene,
parsed,
observation.SubjectName,
observation.ObjectName,
FirstConfigured(observation.Predicate, observation.ObservationType),
string.Join(" ", new[] { observation.Description, observation.Evidence }
.Where(text => !string.IsNullOrWhiteSpace(text)).Distinct(StringComparer.OrdinalIgnoreCase)),
observation.Confidence,
existingEvents);
}
}
private static void AddRelationship(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyList<CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
string? characterAName,
string? characterBName,
string? relationshipSignal,
string? evidence,
decimal? confidence,
IReadOnlyList<RelationshipEvent> existingEvents,
SceneIntelligenceRelationship? proposal = null)
{
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 classification = RelationshipTypeFromEvidence(relationshipSignal, evidence, null, characterA.CharacterName, characterB.CharacterName);
var factual = proposal?.ProposalKind == "Factual"
|| (proposal?.ProposalKind is null && classification.Type is "Sibling" or "Parent" or "Child" or "Adoptive Parent" or "Biological Parent");
var kind = factual ? "Factual" : proposal?.ProposalKind == "State" ? "State" : "Development";
var appearance = new RelationshipAppearanceImport(importedScene.SceneID, importedScene.ChapterNumber,
importedScene.SceneNumber, importedScene.SortOrder, importedScene.SceneTitle, relationshipSignal, evidence, confidence, false);
if (!factual && (string.IsNullOrWhiteSpace(evidence)
|| (proposal?.ProposalKind is null && !HasMeaningfulRelationshipEvent(appearance)))) return;
var reciprocal = proposal?.IsReciprocal ?? (factual && classification.Type == "Sibling");
var type = factual ? FirstConfigured(proposal?.FactualRelationshipType, classification.Type)! : "Other";
var state = FirstConfigured(proposal?.RelationshipState, RelationshipStateNameFromSignal(relationshipSignal))!;
var key = DevelopmentKey(importedScene.SceneID, characterA.CharacterID, characterB.CharacterID, reciprocal, kind, $"{relationshipSignal}|{state}", evidence);
if (groups.ContainsKey(key)) return;
var existing = existingByPair.FirstOrDefault(r => r.IsReciprocal == reciprocal && r.IsPermanent == factual
&& string.Equals(r.RelationshipTypeName, type, StringComparison.OrdinalIgnoreCase)
&& ((r.CharacterAID == characterA.CharacterID && r.CharacterBID == characterB.CharacterID)
|| (reciprocal && r.CharacterAID == characterB.CharacterID && r.CharacterBID == characterA.CharacterID)));
var candidate = new RelationshipCandidate(key, characterA.CharacterID, characterA.CharacterName,
characterB.CharacterID, characterB.CharacterName, type, existing?.CharacterRelationshipID,
existing is null ? null : $"{existing.CharacterAName} / {existing.RelationshipTypeName} / {existing.CharacterBName}")
{
ProposalKind = kind, RelationshipState = state, IsReciprocal = reciprocal,
IsKnownToOtherCharacter = proposal?.IsKnownToOtherCharacter ?? false,
Intensity = proposal?.Intensity is >= 1 and <= 10 ? proposal.Intensity : null,
Development = FirstConfigured(proposal?.Development, evidence, relationshipSignal),
NeedsDirectionReview = proposal?.IsReciprocal is null,
Confidence = confidence, FirstAppearanceNote = BuildSceneReference(importedScene),
LastAppearanceNote = BuildSceneReference(importedScene), ExampleScene = BuildFirstAppearance(importedScene, parsed),
ExampleContext = evidence
};
candidate.Appearances.Add(appearance);
groups[key] = candidate;
}
internal static string DevelopmentKey(int sceneId, int a, int b, bool reciprocal, string kind, string? signal, string? evidence)
{
var participants = reciprocal ? PairKey(a, b) : $"{a}>{b}";
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes($"{Normalise(signal)}|{Normalise(evidence)}")))[..24];
return $"scene:{sceneId}:{participants}:{kind}:{hash}";
}
private static bool PairNamesMatch(string? a, string? b, string? c, string? d)
=> (Normalise(a) == Normalise(c) && Normalise(b) == Normalise(d))
|| (Normalise(a) == Normalise(d) && Normalise(b) == Normalise(c));
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
}
}
return index;
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
{
return rangeMatch;
}
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
{
return direct;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static bool IsRelationshipObservation(SceneIntelligenceObservation observation)
{
var type = Clean(observation.ObservationType);
if (!type.Contains("relationship", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("character", StringComparison.OrdinalIgnoreCase)
&& !RelationshipSignalWords.Any(word => type.Contains(word, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
return IsCharacterEntity(observation.SubjectEntityType)
&& IsCharacterEntity(observation.ObjectEntityType)
&& !string.IsNullOrWhiteSpace(observation.SubjectName)
&& !string.IsNullOrWhiteSpace(observation.ObjectName);
}
private static int ResolveRelationshipTypeID(IReadOnlyList<RelationshipType> types, int? selectedRelationshipTypeId, string? suggestedType)
=> selectedRelationshipTypeId.HasValue && types.Any(type => type.RelationshipTypeID == selectedRelationshipTypeId.Value)
? selectedRelationshipTypeId.Value
: types.FirstOrDefault(type => string.Equals(type.TypeName, suggestedType, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.First().RelationshipTypeID;
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
{
var stateName = states.FirstOrDefault(x => string.Equals(x.StateName, signal, StringComparison.OrdinalIgnoreCase))?.StateName
?? RelationshipStateNameFromSignal(signal);
return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.First().RelationshipStateID;
}
private static string RelationshipStateNameFromSignal(string? signal)
{
var value = Normalise(signal);
return
value.Contains("reconcil") ? "Reconciled" :
value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" :
HasRomanticRelationshipEvidence(signal) ? "Romantic" :
value.Contains("distrust") || value.Contains("mistrust") || value.Contains("suspect") ? "Distrustful" :
value.Contains("protect") ? "Protective" :
value.Contains("friend") || value.Contains("ally") || value.Contains("support") || value.Contains("trust") || value.Contains("hug") || value.Contains("gift") ? "Friendly" :
value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" :
value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
value.Contains("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" :
value.Contains("tense") || value.Contains("tension") ? "Tense" :
"Unknown";
}
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 relationshipText = NormaliseForMatching($"{evidence} {characterAName} {characterBName}");
// Scene-wide romance/conflict can belong to entirely different characters.
var pairContext = ContextForPair(sceneContext, characterAName, characterBName);
var explicitText = NormaliseForMatching($"{evidence} {pairContext}");
var allText = NormaliseForMatching($"{signal} {evidence}");
if (HasDirectSiblingEvidence(evidence, characterAName, characterBName))
{
return new("Sibling", 490);
}
if (HasDirectParentEvidence(evidence, characterAName, characterBName))
{
return new("Parent", 480);
}
if (ContainsAny(relationshipText, "best friend", "best friends", "closest friend")
&& !ContainsAny(relationshipText, "not best friends", "not her best friend", "not his best friend"))
return new("Best Friend", 475);
if (ContainsAny(relationshipText, "close friend", "friend since", "childhood friend"))
return new("Friend", 465);
if (ContainsAny(explicitText, "neighbour", "neighbor", "next door"))
{
return new("Neighbour", 470);
}
if (ContainsAny(explicitText, "coworker", "co worker", "co-worker", "colleague", "workmate", "coworkers", "co workers", "co-workers", "colleagues"))
{
return new("Professional", 460);
}
if (ContainsAny(explicitText, "examiner", "instructor", "learner", "teacher", "student", "mentor", "pupil", "tutor"))
{
return new("Educational", 455);
}
if (ContainsAny(relationshipText, "aunt", "uncle", "cousin", "family", "like a sister", "like a brother"))
{
return new("Family", 420);
}
if (ContainsAny(relationshipText, "rivals", "rivalry", "compete against", "competing against", "competitors", "compete for", "competing for")
&& !ContainsAny(relationshipText, "not rivals", "no rivalry"))
return new("Rival", 370);
if (ContainsAny(relationshipText, "enemy", "enemies", "attack", "attacks", "attacked", "assault", "assaults", "assaulted", "assaulting", "slap", "slaps",
"slapped", "threaten", "threatens", "threatened", "violence", "drugged", "groped", "hostile enemies"))
return new("Enemy", 365);
if (ContainsAny(relationshipText, "never speak", "estranged from", "cut off contact"))
return new("Estranged From", 355);
if (HasRomanticRelationshipEvidence(evidence))
return new("Romantic", 360);
if (ContainsAny(allText, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor", "examiner"))
{
return new("Educational", 350);
}
if (ContainsAny(explicitText, "police", "detective", "suspect", "witness", "guard", "prisoner", "investigator", "person of interest", "social worker", "social services"))
{
return new("Authority", 340);
}
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(explicitText, "colleague", "coworker", "co worker", "co-worker", "boss", "employee", "manager", "client", "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(explicitText, "ally", "protector", "supporter", "confidant", "protects", "supports"))
{
return new("Ally", 250);
}
if (ContainsAny(relationshipText, "has met", "barely knows", "acquaintance", "acquaintances", "knows of", "aware of", "recognises", "recognizes", "met before", "know each other", "knows him", "knows her"))
return new("Acquaintance", 100);
return new("Other", 0);
}
private static string ContextForPair(string? context, string? characterA, string? characterB)
{
if (string.IsNullOrWhiteSpace(characterA) || string.IsNullOrWhiteSpace(characterB)) return string.Empty;
return string.Join(" ", Regex.Split(context ?? string.Empty, @"(?<=[.!?])\s+")
.Where(sentence => ContainsAny(NormaliseForMatching(sentence), characterA)
&& ContainsAny(NormaliseForMatching(sentence), characterB)));
}
private static bool HasRomanticRelationshipEvidence(string? evidence)
{
var value = NormaliseForMatching(evidence);
if (Regex.IsMatch(evidence ?? string.Empty, @"\b(?:about|discusses?|describes?)\b.{0,50}\b(?:boyfriend|girlfriend|lover|romantic relationship)\b", RegexOptions.IgnoreCase)) return false;
if (Regex.IsMatch(evidence ?? string.Empty, @"\b(?:not|no|never|isn't|aren't)\b.{0,25}\b(?:romantic|boyfriend|girlfriend|dating|in love|a couple)\b", RegexOptions.IgnoreCase)) return false;
if (ContainsAny(value, "not romantic", "no romantic", "not a couple", "not dating", "not in love", "unwanted", "forced kiss", "assault", "assaulting", "assaulted", "against her will", "against his will")) return false;
return ContainsAny(value, "romantic relationship", "romantically involved", "in a relationship", "dating each other", "they are dating", "they are a couple", "boyfriend", "girlfriend", "husband", "wife", "spouse", "lovers", "in love with", "has a crush on", "romantic feelings for", "wants to date", "wants a relationship with", "wants a romantic relationship", "hopes for a relationship", "asks her on a date", "asks him on a date", "asks them on a date", "romantic interest in", "romantic attraction to", "engaged to", "married to");
}
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
{
var value = $"{appearance.Signal} {appearance.Evidence}";
if (Regex.IsMatch(value, @"\b(?:merely|simply|only) (?:appear|present|share|co.present)|\bno (?:meaningful )?(?:interaction|change)\b", RegexOptions.IgnoreCase)) return false;
return RelationshipEventWords.Any(word => Regex.IsMatch(value, $@"\b{Regex.Escape(word)}\b", RegexOptions.IgnoreCase));
}
private static bool HasDirectSiblingEvidence(string? evidence, string? characterAName, string? characterBName)
{
var clean = Clean(evidence);
if (string.IsNullOrWhiteSpace(clean)
|| FigurativeSibling.IsMatch(clean)
|| ReportedThirdPartyKinship.IsMatch(clean)
|| Regex.IsMatch(clean, @"\b(?:not|never)\s+(?:(?:his|her|my|their|a|the)\s+)?(?:sisters?|brothers?|siblings?)\b", RegexOptions.IgnoreCase))
{
return false;
}
var normalised = NormaliseForMatching(clean);
return HasNamedSiblingEvidence(normalised, characterAName, characterBName)
|| ContainsAny(normalised, "they are sisters", "they are brothers", "they are siblings", "we are sisters", "we re sisters", "we are brothers", "we re brothers", "between siblings", "the sisters", "the brothers", "sibling relationship", "my brother", "my sister", "her brother", "her sister", "his brother", "his sister", "their brother", "their sister", "my older sister", "my younger sister", "my older brother", "my younger brother", "her older sister", "her younger sister", "his older brother", "his younger brother")
|| ContainsAny(normalised, "two siblings", "both siblings", "the siblings")
|| HasOwnedKinshipBetweenParticipants("brother", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("sister", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("sibling", characterAName, characterBName);
}
private static bool HasNamedSiblingEvidence(string value, string? a, string? b)
{
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false;
foreach (var (first, second) in new[] { (a, b), (b, a) })
{
if (ContainsAny(value, $"{first} and {second} are sisters", $"{first} and {second} are brothers", $"{first} and {second} are siblings")) return true;
foreach (var kin in new[] { "sister", "brother", "sibling", "older sister", "younger sister", "elder sister", "older brother", "younger brother", "elder brother" })
if (ContainsAny(value, $"{first} is {second}'s {kin}", $"{first} identified as {second}'s {kin}", $"{first} is identified as {second}'s {kin}", $"{first} described as {second}'s {kin}", $"{first} {second}'s {kin}", $"{first}'s {kin} {second}", $"{first} is the {kin} of {second}")) return true;
}
return false;
}
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")
|| HasNamedParentEvidence(normalised, characterAName, characterBName)
|| HasNamedParentEvidence(normalised, characterBName, characterAName)
|| HasOwnedKinshipBetweenParticipants("mother", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("father", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("mum", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("mom", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("dad", characterAName, characterBName);
}
private static bool HasNamedParentEvidence(string normalisedEvidence, string? possibleParentName, string? possibleChildName)
{
var parent = Clean(possibleParentName);
var child = Clean(possibleChildName);
if (string.IsNullOrWhiteSpace(parent) || string.IsNullOrWhiteSpace(child))
{
return false;
}
return ContainsAny(
normalisedEvidence,
$"{child}'s mother {parent}",
$"{child}'s mum {parent}",
$"{child}'s mom {parent}",
$"{child}'s father {parent}",
$"{child}'s dad {parent}");
}
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);
private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) CandidateFirstStoryOrder(RelationshipCandidate candidate)
=> candidate.Appearances
.Select(StoryOrder)
.OrderBy(order => order.ChapterNumber)
.ThenBy(order => order.SceneNumber)
.ThenBy(order => order.SortOrder)
.ThenBy(order => order.SceneID)
.FirstOrDefault((decimal.MaxValue, decimal.MaxValue, int.MaxValue, int.MaxValue));
private static string RelationshipStateAtScene(
string initialState,
IEnumerable<(int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder, string StateName)> events,
int sceneId)
{
var ordered = events.OrderBy(item => item.ChapterNumber).ThenBy(item => item.SceneNumber).ThenBy(item => item.SortOrder).ThenBy(item => item.SceneID).ToList();
var scene = ordered.FirstOrDefault(item => item.SceneID == sceneId);
if (scene.SceneID == 0)
{
return initialState;
}
return ordered
.Where(item => (item.ChapterNumber, item.SceneNumber, item.SortOrder, item.SceneID)
.CompareTo((scene.ChapterNumber, scene.SceneNumber, scene.SortOrder, scene.SceneID)) <= 0)
.LastOrDefault().StateName ?? initialState;
}
private static string RelationshipLabel(RelationshipCandidate candidate)
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
private static IReadOnlyList<SelectListItem> ToRelationshipTypeOptions(IEnumerable<RelationshipType> relationshipTypes)
=> relationshipTypes
.OrderBy(type => type.CategorySortOrder)
.ThenBy(type => type.SortOrder)
.ThenBy(type => type.TypeName)
.Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
.ToList();
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked)
{
batch.RelationshipDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.RelationshipDecisions.Add(new OnboardingStoryIntelligenceRelationshipDecision
{
Key = key,
Action = action,
CharacterRelationshipID = relationshipId,
RelationshipLabel = label,
CreatedOrLinked = createdOrLinked
});
}
private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static string PairKey(int characterAId, int characterBId)
=> characterAId <= characterBId ? $"{characterAId}:{characterBId}" : $"{characterBId}:{characterAId}";
private static string? FirstConfigured(params string?[] values)
=> values.Select(Clean).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string NormaliseForMatching(string? value)
=> $" {NonWord.Replace(Normalise(value), " ")} ";
private static bool ContainsAny(string value, params string[] needles)
=> needles.Any(needle => value.Contains(NormaliseForMatching(needle), StringComparison.Ordinal));
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
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 =
[
"friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy",
"rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting",
"trust", "hug", "gift", "present", "proposal", "engaged", "attraction"
];
private static readonly string[] RelationshipEventWords =
[
"begins", "begin", "starts", "first meeting", "deteriorates", "deteriorate",
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects",
"becomes", "become", "establishes", "couple",
"attraction", "romantic", "proposes", "proposal", "engaged", "accepts", "entrust", "betray", "confides", "asks", "helps"
];
private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);
private sealed class RelationshipCandidate(
string key,
int characterAId,
string characterAName,
int characterBId,
string characterBName,
string relationshipType,
int? existingRelationshipId,
string? existingRelationshipLabel)
{
public string ProposalKind { get; init; } = "Development";
public string RelationshipState { get; init; } = "Unknown";
public bool IsReciprocal { get; init; }
public bool IsKnownToOtherCharacter { get; init; }
public bool NeedsDirectionReview { get; init; }
public int? Intensity { get; init; }
public string? Development { get; init; }
public string Key { get; } = key;
public int CharacterAID { get; } = characterAId;
public string CharacterAName { get; } = characterAName;
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; }
public string? FirstAppearanceNote { get; set; }
public string? LastAppearanceNote { get; set; }
public string? ExampleScene { get; set; }
public string? ExampleContext { get; set; }
public List<RelationshipAppearanceImport> Appearances { get; } = [];
}
private sealed record RelationshipAppearanceImport(
int SceneID,
decimal ChapterNumber,
decimal SceneNumber,
int SortOrder,
string SceneTitle,
string? Signal,
string? Evidence,
decimal? Confidence,
bool AlreadyLinked);
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Clean(name);
if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
{
return;
}
byName[key] = new CharacterMatch(characterId, characterName);
}
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,
"Best Friend" or "Friend" or "Romantic" or "Criminal" or "Rival" or "Enemy" => 3,
"Family" or "Ally" => 2,
_ => 0
};
private sealed record CharacterMatch(int CharacterID, string CharacterName);
}