1033 lines
47 KiB
C#
1033 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,
|
|
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
|
|
{
|
|
Key = candidate.Key,
|
|
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
|
|
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
|
|
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
|
|
SaveRelationshipSelections(batch, choices.Values);
|
|
if (choices.Count == 0)
|
|
{
|
|
if (data.Candidates.Count == 0)
|
|
{
|
|
batch.RelationshipStageComplete = completeReview;
|
|
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
|
|
if (completeReview)
|
|
{
|
|
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 relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
|
|
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)
|
|
{
|
|
if (!choice.RelationshipTypeID.HasValue || !relationshipTypesById.ContainsKey(choice.RelationshipTypeID.Value))
|
|
{
|
|
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid relationship type for each relationship you want to create." };
|
|
}
|
|
|
|
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
|
|
{
|
|
ProjectID = batch.ProjectID,
|
|
CharacterAID = candidate.CharacterAID,
|
|
CharacterBID = candidate.CharacterBID,
|
|
RelationshipTypeID = choice.RelationshipTypeID.Value,
|
|
IsPermanent = false,
|
|
StartSceneID = candidate.Appearances.OrderBy(StoryOrder).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())
|
|
.OrderBy(StoryOrder))
|
|
{
|
|
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 = completeReview;
|
|
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
|
|
{
|
|
RelationshipsCreated = created,
|
|
RelationshipsLinked = linkedExisting,
|
|
RelationshipsMerged = merged,
|
|
RelationshipsIgnored = ignored,
|
|
RelationshipEventsCreated = eventsCreated
|
|
};
|
|
if (completeReview)
|
|
{
|
|
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 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);
|
|
}
|
|
}
|
|
|
|
var candidates = groups.Values
|
|
.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,
|
|
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, 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);
|
|
candidate = new RelationshipCandidate(
|
|
key,
|
|
characterA.CharacterID,
|
|
characterA.CharacterName,
|
|
characterB.CharacterID,
|
|
characterB.CharacterName,
|
|
classification.Type,
|
|
existingMatch?.CharacterRelationshipID,
|
|
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
|
|
candidate.RelationshipTypeClassification = classification;
|
|
groups[key] = candidate;
|
|
}
|
|
|
|
if (classification.CompareTo(candidate.RelationshipTypeClassification) > 0
|
|
|| classification.CompareTo(candidate.RelationshipTypeClassification) == 0 && (confidence ?? 0m) >= (candidate.Confidence ?? 0m))
|
|
{
|
|
candidate.RelationshipType = classification.Type;
|
|
candidate.RelationshipTypeClassification = classification;
|
|
}
|
|
|
|
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.ChapterNumber,
|
|
importedScene.SceneNumber,
|
|
importedScene.SortOrder,
|
|
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<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 = 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" :
|
|
value.Contains("romantic") || value.Contains("attraction") || value.Contains("lover") || value.Contains("love") || value.Contains("couple") ? "Romantic" :
|
|
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}");
|
|
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 new("Friend", 500);
|
|
}
|
|
|
|
if (HasDirectSiblingEvidence(evidence, characterAName, characterBName))
|
|
{
|
|
return new("Sibling", 490);
|
|
}
|
|
|
|
if (HasDirectParentEvidence(evidence, characterAName, characterBName))
|
|
{
|
|
return new("Parent", 480);
|
|
}
|
|
|
|
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", "candidate", "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(allText,
|
|
"rival", "enemy", "adversarial", "conflict", "mistrust", "distrust", "antagonist", "competitor",
|
|
"confront", "attack", "attacks", "attacked", "assault", "assaults", "assaulted", "slap", "slaps",
|
|
"slapped", "threat", "threaten", "threatens", "threatened", "violence", "never speak", "disgust",
|
|
"betray", "betrays", "betrayed", "spiked", "drugged", "grope", "gropes", "groped", "force",
|
|
"forces", "forced", "insult", "insults", "anger", "hostile"))
|
|
{
|
|
return new("Rival", 370);
|
|
}
|
|
|
|
if (ContainsAny(allText, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like", "kiss", "proposal", "engaged", "couple"))
|
|
{
|
|
return new("Romantic", 360);
|
|
}
|
|
|
|
if (ContainsAny(allText, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor", "examiner", "candidate"))
|
|
{
|
|
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);
|
|
}
|
|
|
|
return new("Unknown", 0);
|
|
}
|
|
|
|
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
|
|
{
|
|
var value = $"{appearance.Signal} {appearance.Evidence}";
|
|
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")
|
|
|| 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 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 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 void SaveRelationshipSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
|
|
{
|
|
foreach (var choice in choices)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(choice.Key) || choice.RelationshipTypeID is not { } relationshipTypeId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
batch.RelationshipSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
|
|
batch.RelationshipSelections.Add(new OnboardingStoryIntelligenceRelationshipSelection
|
|
{
|
|
Key = choice.Key,
|
|
RelationshipTypeID = relationshipTypeId
|
|
});
|
|
}
|
|
}
|
|
|
|
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 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 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", "meet", "met", "deteriorates", "deteriorate",
|
|
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
|
|
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects",
|
|
"becomes", "become", "establishes", "established", "explicit", "couple", "relationship",
|
|
"attraction", "romantic", "proposes", "proposal", "engaged", "accepts"
|
|
];
|
|
|
|
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 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,
|
|
"Friend" or "Romantic" or "Criminal" or "Rival" => 3,
|
|
"Family" or "Ally" => 2,
|
|
_ => 0
|
|
};
|
|
|
|
private sealed record CharacterMatch(int CharacterID, string CharacterName);
|
|
}
|