PlotDirector/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs

671 lines
31 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
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);
}
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 data = await BuildCandidateDataAsync(batch);
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,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{
Key = candidate.Key,
CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName,
RelationshipType = 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)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing relationships." };
}
var choices = form.Relationships
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
{
batch.RelationshipStageComplete = true;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Relationship review completed. No relationship decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one relationship to create, link or ignore." };
}
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var created = 0;
var linkedExisting = 0;
var merged = 0;
var ignored = 0;
var eventsCreated = 0;
var resolvedRelationships = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Ignore, null, RelationshipLabel(candidate), false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relationshipId = string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
? candidate.ExistingRelationshipID
: null;
if (!relationshipId.HasValue)
{
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
{
ProjectID = batch.ProjectID,
CharacterAID = candidate.CharacterAID,
CharacterBID = candidate.CharacterBID,
RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType),
IsPermanent = false,
StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID,
EndSceneID = null,
IsKnownToReader = true,
Notes = BuildRelationshipNotes(candidate),
IsInitialRelationship = false,
InitialBookID = batch.BookID,
ReaderInitiallyKnows = true,
InitialRelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipType),
InitialIntensity = ConfidenceIntensity(candidate.Confidence),
IsReciprocal = true
});
created++;
}
else
{
linkedExisting++;
}
resolvedRelationships[candidate.Key] = relationshipId.Value;
AddDecision(batch, candidate.Key, choice.Action, relationshipId.Value, RelationshipLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var targetKey = Clean(choice.AliasTargetKey);
if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidatesByKey.ContainsKey(targetKey)
|| !resolvedRelationships.TryGetValue(targetKey, out var targetRelationshipId))
{
logger.LogWarning(
"Story Intelligence relationship merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
resolvedRelationships[candidate.Key] = targetRelationshipId;
merged++;
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Alias, targetRelationshipId, RelationshipLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| !resolvedRelationships.TryGetValue(candidate.Key, out var relationshipId))
{
continue;
}
foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID)
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First()))
{
if (!HasMeaningfulRelationshipEvent(appearance))
{
continue;
}
await characters.SaveRelationshipEventAsync(new RelationshipEvent
{
CharacterRelationshipID = relationshipId,
SceneID = appearance.SceneID,
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, appearance.Signal ?? candidate.RelationshipType),
Intensity = ConfidenceIntensity(appearance.Confidence ?? candidate.Confidence),
IsKnownToOtherCharacter = true,
Description = BuildRelationshipEventDescription(candidate, appearance)
});
eventsCreated++;
}
}
batch.RelationshipStageComplete = true;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
{
RelationshipsCreated = created,
RelationshipsLinked = linkedExisting,
RelationshipsMerged = merged,
RelationshipsIgnored = ignored,
RelationshipEventsCreated = eventsCreated
};
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}",
batch.BatchID,
created,
linkedExisting,
merged,
ignored,
eventsCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = eventsCreated,
Message = $"Relationships imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored, {eventsCreated:N0} relationship event(s) added."
};
}
private async Task<RelationshipCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID);
var existingByPair = existingRelationships
.GroupBy(relationship => PairKey(relationship.CharacterAID, relationship.CharacterBID))
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var groups = new Dictionary<string, RelationshipCandidate>(StringComparer.OrdinalIgnoreCase);
var alreadyLinked = 0;
var hasCommittedScenes = false;
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
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);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, 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);
}
}
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.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,
IReadOnlyDictionary<string, 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);
}
foreach (var observation in parsed.Observations ?? [])
{
if (!IsRelationshipObservation(observation))
{
continue;
}
AddRelationship(
groups,
characterIndex,
existingByPair,
importedScene,
parsed,
observation.SubjectName,
observation.ObjectName,
FirstConfigured(observation.Predicate, observation.ObservationType),
FirstConfigured(observation.Description, observation.Evidence),
observation.Confidence,
existingEvents);
}
}
private static void AddRelationship(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
string? characterAName,
string? characterBName,
string? relationshipSignal,
string? evidence,
decimal? confidence,
IReadOnlyList<RelationshipEvent> existingEvents)
{
var characterA = characterIndex.Find(characterAName);
var characterB = characterIndex.Find(characterBName);
if (characterA is null || characterB is null || characterA.CharacterID == characterB.CharacterID)
{
return;
}
var key = PairKey(characterA.CharacterID, characterB.CharacterID);
if (!groups.TryGetValue(key, out var candidate))
{
existingByPair.TryGetValue(key, out var existingMatch);
candidate = new RelationshipCandidate(
key,
characterA.CharacterID,
characterA.CharacterName,
characterB.CharacterID,
characterB.CharacterName,
RelationshipTypeFromSignal(relationshipSignal),
existingMatch?.CharacterRelationshipID,
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
groups[key] = candidate;
}
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal);
}
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.FirstAppearanceNote ??= BuildSceneReference(importedScene);
candidate.LastAppearanceNote = BuildSceneReference(importedScene);
candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
candidate.ExampleContext ??= Clean(evidence);
candidate.Appearances.Add(new RelationshipAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
relationshipSignal,
evidence,
confidence,
candidate.ExistingRelationshipID.HasValue && existingEvents.Any(existing => existing.CharacterRelationshipID == candidate.ExistingRelationshipID.Value)));
}
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<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
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 MatchRelationshipType(IReadOnlyList<RelationshipType> types, string? signal)
{
var normalised = Normalise(signal);
var mapped = RelationshipTypeFromSignal(signal);
return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), 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 value = Normalise(signal);
var stateName =
value.Contains("reconcil") ? "Reconciled" :
value.Contains("romantic") || value.Contains("lover") || value.Contains("love") ? "Romantic" :
value.Contains("friend") || value.Contains("ally") || value.Contains("support") ? "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";
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 RelationshipTypeFromSignal(string? signal)
{
var value = Normalise(signal);
if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent";
if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child";
if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling";
if (value.Contains("spouse") || value.Contains("husband") || value.Contains("wife") || value.Contains("married")) return "Spouse";
if (value.Contains("romantic") || value.Contains("lover") || value.Contains("love")) return "Lover";
if (value.Contains("friend")) return "Friend";
if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy";
if (value.Contains("rival")) return "Rival";
if (value.Contains("suspicious") || value.Contains("suspect")) return "Suspicious Of";
if (value.Contains("neighbour") || value.Contains("neighbor")) return "Neighbour";
if (value.Contains("teacher")) return "Teacher";
if (value.Contains("student")) return "Student";
if (value.Contains("employer") || value.Contains("boss")) return "Employer";
if (value.Contains("employee")) return "Employee";
if (value.Contains("colleague") || value.Contains("professional") || value.Contains("police") || value.Contains("detective") || value.Contains("sergeant")) return "Colleague";
if (value.Contains("ally") || value.Contains("support")) return "Ally";
if (value.Contains("guardian") || value.Contains("protector")) return "Protector";
if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From";
return "Unknown";
}
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
{
var value = $"{appearance.Signal} {appearance.Evidence}";
return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
}
private static string BuildRelationshipNotes(RelationshipCandidate candidate)
{
var lines = new List<string> { "Imported from Story Intelligence relationship review." };
if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
lines.Add(candidate.ExampleContext);
}
if (!string.IsNullOrWhiteSpace(candidate.FirstAppearanceNote))
{
lines.Add($"First detected: {candidate.FirstAppearanceNote}");
}
return string.Join(Environment.NewLine, lines);
}
private static string BuildRelationshipEventDescription(RelationshipCandidate candidate, RelationshipAppearanceImport appearance)
=> FirstConfigured(
Clean(appearance.Evidence),
Clean(appearance.Signal),
$"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}") ?? RelationshipLabel(candidate);
private static string RelationshipLabel(RelationshipCandidate candidate)
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"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 int? ConfidenceIntensity(decimal? confidence)
=> confidence.HasValue ? Math.Clamp((int)Math.Round(confidence.Value * 10m, MidpointRounding.AwayFromZero), 1, 10) : null;
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
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 Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly string[] RelationshipSignalWords =
[
"friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy",
"rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting"
];
private static readonly string[] RelationshipEventWords =
[
"begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate",
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects"
];
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 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 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 SceneNumber,
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)
=> byName.TryGetValue(Clean(name), out var match) ? match : null;
}
private sealed record CharacterMatch(int CharacterID, string CharacterName);
}