928 lines
39 KiB
C#
928 lines
39 KiB
C#
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using PlotLine.Data;
|
||
using PlotLine.Models;
|
||
using PlotLine.ViewModels;
|
||
|
||
namespace PlotLine.Services;
|
||
|
||
public interface IStoryIntelligenceCharacterImportService
|
||
{
|
||
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form);
|
||
}
|
||
|
||
public sealed class StoryIntelligenceCharacterImportService(
|
||
IStoryIntelligenceResultRepository runs,
|
||
ICharacterRepository characters,
|
||
ISceneRepository scenes,
|
||
IStoryIntelligencePipelineStateService pipelineState,
|
||
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
||
{
|
||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||
{
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
PropertyNameCaseInsensitive = true
|
||
};
|
||
|
||
private static readonly HashSet<string> IgnoredNames = CreateSet(
|
||
"narrator", "unknown", "unknown narrator", "i", "me", "my", "mine", "we", "us", "our", "ours",
|
||
"you", "he", "him", "his", "she", "her", "hers", "they", "them", "their", "theirs");
|
||
|
||
private static readonly HashSet<string> GenericPeople = CreateSet(
|
||
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
|
||
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
|
||
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group", "police", "officer",
|
||
"officers", "staff", "customers", "boys", "girls", "men", "women", "neighbour", "neighbours",
|
||
"passenger", "passengers", "examiner", "examiners", "instructor", "instructors", "candidate", "candidates",
|
||
"social worker", "social workers", "responders");
|
||
|
||
private static readonly HashSet<string> GenericGroupHeads = CreateSet(
|
||
"man", "men", "woman", "women", "boy", "boys", "girl", "girls", "child", "children", "person", "people",
|
||
"group", "crowd", "family", "neighbour", "neighbours", "passenger", "passengers", "examiner", "examiners",
|
||
"driver", "drivers", "instructor", "instructors", "candidate", "candidates", "officer", "officers", "staff", "customers",
|
||
"responders", "worker", "workers", "lad", "lads");
|
||
|
||
private static readonly HashSet<string> QuantityWords = CreateSet(
|
||
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "several", "some",
|
||
"many", "few", "couple", "various", "other", "groups");
|
||
|
||
private static readonly HashSet<string> DeterminerWords = CreateSet(
|
||
"the", "a", "an", "my", "your", "his", "her", "our", "their");
|
||
|
||
private static readonly HashSet<string> FamilyRoleNames = CreateSet(
|
||
"aunt", "uncle", "mother", "father", "mum", "mom", "mam", "dad", "brother", "sister", "grandmother",
|
||
"grandfather", "grandma", "grandad", "granddad");
|
||
|
||
private static readonly HashSet<string> CharacterTitlePrefixes = CreateSet(
|
||
"mr", "mrs", "miss", "ms", "dr", "reverend", "rev", "detective", "sergeant", "ds", "aunt", "uncle");
|
||
|
||
private static readonly HashSet<string> NonCharacterNames = CreateSet(
|
||
"christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room",
|
||
"hallway", "kitchen", "garage", "pavement", "road", "desk", "table", "phone", "letter", "note",
|
||
"social services", "under-flyover / link road");
|
||
|
||
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
|
||
private static readonly Regex LeadingQuantity = new(@"^\s*(\d+|one|two|three|four|five|six|seven|eight|nine|ten|several|some|many|few|couple|various|other)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||
|
||
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||
{
|
||
var data = await BuildCandidateDataAsync(batch);
|
||
var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||
return new StoryIntelligenceCharacterReviewViewModel
|
||
{
|
||
HasCommittedScenes = data.HasCommittedScenes,
|
||
CanImport = visibleCandidates.Count > 0,
|
||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
||
{
|
||
Key = candidate.Key,
|
||
CharacterName = candidate.DisplayName,
|
||
ImportName = candidate.DisplayName,
|
||
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
||
Confidence = ConfidenceBand(candidate.Confidence),
|
||
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
|
||
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
||
ExistingCharacterID = candidate.ExistingCharacterID,
|
||
ExistingCharacterName = candidate.ExistingCharacterName
|
||
}).ToList()
|
||
};
|
||
}
|
||
|
||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form)
|
||
{
|
||
var data = await BuildCandidateDataAsync(batch);
|
||
if (!data.HasCommittedScenes)
|
||
{
|
||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." };
|
||
}
|
||
|
||
var choices = form.Characters
|
||
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
|
||
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
|
||
if (choices.Count == 0)
|
||
{
|
||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create, link or ignore." };
|
||
}
|
||
|
||
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||
var roleTypes = lookupData.RoleTypes;
|
||
var presenceTypes = lookupData.PresenceTypes;
|
||
var created = 0;
|
||
var linkedExisting = 0;
|
||
var ignored = 0;
|
||
var aliased = 0;
|
||
var linkedAppearances = 0;
|
||
var povLinks = 0;
|
||
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
var candidateByKey = 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, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
ignored++;
|
||
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Ignore, candidate.DisplayName, null, false);
|
||
continue;
|
||
}
|
||
|
||
if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var requestedName = Clean(choice.ImportName);
|
||
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
||
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
||
var matchedExistingId = linkExisting
|
||
? candidate.ExistingCharacterID
|
||
?? existingIndex.Find(importName)?.CharacterID
|
||
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
|
||
: null;
|
||
var characterId = matchedExistingId;
|
||
|
||
if (!characterId.HasValue)
|
||
{
|
||
characterId = await characters.SaveCharacterAsync(new Character
|
||
{
|
||
ProjectID = batch.ProjectID,
|
||
CharacterName = importName,
|
||
ShortName = FirstName(importName),
|
||
DefaultDescription = candidate.Description
|
||
});
|
||
created++;
|
||
}
|
||
else
|
||
{
|
||
linkedExisting++;
|
||
}
|
||
|
||
AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value);
|
||
AddResolvedName(resolvedCharacters, importName, characterId.Value);
|
||
foreach (var alias in candidate.Aliases)
|
||
{
|
||
AddResolvedName(resolvedCharacters, alias, characterId.Value);
|
||
await TryAddAliasAsync(characterId.Value, alias, importName);
|
||
}
|
||
|
||
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
||
{
|
||
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||
{
|
||
SceneID = appearance.SceneID,
|
||
CharacterID = characterId.Value,
|
||
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||
AppearanceNotes = appearance.Notes
|
||
});
|
||
linkedAppearances++;
|
||
}
|
||
|
||
resolvedCandidateIds[candidate.Key] = characterId.Value;
|
||
AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true);
|
||
}
|
||
|
||
foreach (var candidate in data.Candidates)
|
||
{
|
||
if (!choices.TryGetValue(candidate.Key, out var choice)
|
||
|| !string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.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, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|
||
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
|
||
{
|
||
logger.LogWarning(
|
||
"Story Intelligence character alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
|
||
candidate.Key,
|
||
targetKey);
|
||
continue;
|
||
}
|
||
|
||
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
|
||
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
|
||
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
|
||
foreach (var alias in candidate.Aliases)
|
||
{
|
||
await TryAddAliasAsync(targetCharacterId, alias, targetName);
|
||
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
|
||
}
|
||
|
||
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
||
{
|
||
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||
{
|
||
SceneID = appearance.SceneID,
|
||
CharacterID = targetCharacterId,
|
||
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
|
||
});
|
||
linkedAppearances++;
|
||
}
|
||
|
||
aliased++;
|
||
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId, true);
|
||
}
|
||
|
||
foreach (var scene in data.SceneAnalyses)
|
||
{
|
||
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
|
||
if (string.IsNullOrWhiteSpace(povName) || !resolvedCharacters.TryGetValue(povName, out var characterId))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
await scenes.UpdatePovCharacterAsync(scene.SceneID, characterId);
|
||
povLinks++;
|
||
}
|
||
|
||
batch.CharacterStageComplete = true;
|
||
batch.LastCharacterImportResult = new StoryIntelligenceCharacterImportBatchResult
|
||
{
|
||
CharactersCreated = created,
|
||
CharactersLinked = linkedExisting,
|
||
CharactersIgnored = ignored,
|
||
CharactersAliased = aliased,
|
||
PovLinksResolved = povLinks,
|
||
ScenePeoplePanelsUpdated = linkedAppearances
|
||
};
|
||
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
||
|
||
logger.LogInformation(
|
||
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
|
||
batch.BatchID,
|
||
created,
|
||
linkedExisting,
|
||
aliased,
|
||
ignored,
|
||
linkedAppearances,
|
||
povLinks);
|
||
|
||
return new StoryIntelligenceImportCommitResult
|
||
{
|
||
Success = true,
|
||
ScenesCreated = linkedAppearances,
|
||
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved."
|
||
};
|
||
}
|
||
|
||
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? characterName, int? characterId, bool createdOrLinked)
|
||
{
|
||
batch.CharacterDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
|
||
batch.CharacterDecisions.Add(new OnboardingStoryIntelligenceCharacterDecision
|
||
{
|
||
Key = key,
|
||
Action = action,
|
||
CharacterName = characterName,
|
||
CharacterID = characterId,
|
||
CreatedOrLinked = createdOrLinked
|
||
});
|
||
}
|
||
|
||
private async Task<CharacterCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
|
||
{
|
||
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||
var groups = new Dictionary<string, CharacterCandidate>(StringComparer.OrdinalIgnoreCase);
|
||
var sceneAnalyses = new List<ImportedSceneAnalysis>();
|
||
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;
|
||
}
|
||
|
||
sceneAnalyses.Add(new ImportedSceneAnalysis(importedScene.SceneID, parsed));
|
||
var existingAppearances = await characters.ListSceneCharactersAsync(importedScene.SceneID);
|
||
alreadyLinked += existingAppearances.Count;
|
||
|
||
AddSceneCharacterMentions(groups, existingIndex, importedScene, parsed, existingAppearances);
|
||
}
|
||
}
|
||
|
||
var candidates = groups.Values
|
||
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
|
||
.Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked))
|
||
.Where(IsVisibleCharacterCandidate)
|
||
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||
.ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||
.ThenBy(candidate => candidate.DisplayName)
|
||
.ToList();
|
||
|
||
return new CharacterCandidateData(hasCommittedScenes, alreadyLinked, candidates, sceneAnalyses);
|
||
}
|
||
|
||
private static void AddMention(
|
||
Dictionary<string, CharacterCandidate> groups,
|
||
CharacterIndex existingIndex,
|
||
Scene importedScene,
|
||
SceneIntelligenceScene parsed,
|
||
SceneIntelligenceCharacter character,
|
||
IReadOnlyList<SceneCharacter> existingAppearances)
|
||
{
|
||
var name = CleanCharacterName(character.Name);
|
||
if (!IsCharacterNameCandidate(name))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var key = ResolveCandidateKey(groups, existingIndex, name);
|
||
if (!groups.TryGetValue(key, out var candidate))
|
||
{
|
||
var match = existingIndex.Find(name);
|
||
candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName);
|
||
groups[key] = candidate;
|
||
}
|
||
else if (!string.Equals(candidate.DisplayName, name, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
candidate.Aliases.Add(name);
|
||
}
|
||
|
||
foreach (var alias in character.Aliases ?? [])
|
||
{
|
||
var cleanAlias = CleanCharacterName(alias);
|
||
if (IsCharacterNameCandidate(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
candidate.Aliases.Add(cleanAlias);
|
||
}
|
||
}
|
||
|
||
var notes = Clean(character.Notes);
|
||
if (string.IsNullOrWhiteSpace(notes) && character.Actions?.Count > 0)
|
||
{
|
||
notes = Clean(string.Join("; ", character.Actions));
|
||
}
|
||
|
||
var appearance = new CharacterAppearanceImport(
|
||
importedScene.SceneID,
|
||
importedScene.SceneNumber,
|
||
importedScene.SceneTitle,
|
||
character.RoleInScene,
|
||
character.MentionedOnly == true,
|
||
notes,
|
||
character.Confidence,
|
||
candidate.ExistingCharacterID.HasValue && existingAppearances.Any(appearance => appearance.CharacterID == candidate.ExistingCharacterID.Value));
|
||
if (character.MentionedOnly == true)
|
||
{
|
||
candidate.MentionedOnlyAppearances.Add(appearance);
|
||
}
|
||
else
|
||
{
|
||
candidate.PresentAppearances.Add(appearance);
|
||
}
|
||
|
||
candidate.Confidence = Max(candidate.Confidence, character.Confidence);
|
||
candidate.Description ??= notes;
|
||
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, character);
|
||
}
|
||
|
||
private static void AddSceneCharacterMentions(
|
||
Dictionary<string, CharacterCandidate> groups,
|
||
CharacterIndex existingIndex,
|
||
Scene importedScene,
|
||
SceneIntelligenceScene parsed,
|
||
IReadOnlyList<SceneCharacter> existingAppearances)
|
||
{
|
||
foreach (var character in parsed.Characters ?? [])
|
||
{
|
||
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
|
||
}
|
||
|
||
var povName = CleanCharacterName(parsed.PointOfView?.CharacterName);
|
||
if (IsCharacterNameCandidate(povName))
|
||
{
|
||
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
|
||
{
|
||
Name = povName,
|
||
RoleInScene = "POV",
|
||
MentionedOnly = false,
|
||
Confidence = parsed.PointOfView?.Confidence ?? 0.7m,
|
||
Notes = "Point of view candidate from Scene Intelligence."
|
||
}, existingAppearances);
|
||
}
|
||
|
||
foreach (var relationship in parsed.Relationships ?? [])
|
||
{
|
||
AddObservedName(groups, existingIndex, importedScene, parsed, relationship.CharacterA, "Relationship participant", false, relationship.Confidence, relationship.RelationshipSignal, existingAppearances);
|
||
AddObservedName(groups, existingIndex, importedScene, parsed, relationship.CharacterB, "Relationship participant", false, relationship.Confidence, relationship.RelationshipSignal, existingAppearances);
|
||
}
|
||
|
||
foreach (var knowledge in parsed.KnowledgeChanges ?? [])
|
||
{
|
||
AddObservedName(groups, existingIndex, importedScene, parsed, knowledge.RecipientCharacter, "Knowledge recipient", false, knowledge.Confidence, knowledge.KnowledgeItem, existingAppearances);
|
||
AddObservedName(groups, existingIndex, importedScene, parsed, knowledge.SourceCharacter, "Knowledge source", true, knowledge.Confidence, knowledge.KnowledgeItem, existingAppearances);
|
||
}
|
||
|
||
foreach (var observation in parsed.Observations ?? [])
|
||
{
|
||
if (IsCharacterEntity(observation.SubjectEntityType))
|
||
{
|
||
AddObservedName(groups, existingIndex, importedScene, parsed, observation.SubjectName, observation.ObservationType, false, observation.Confidence, observation.Description, existingAppearances);
|
||
}
|
||
|
||
if (IsCharacterEntity(observation.ObjectEntityType))
|
||
{
|
||
AddObservedName(groups, existingIndex, importedScene, parsed, observation.ObjectName, observation.ObservationType, true, observation.Confidence, observation.Description, existingAppearances);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static void AddObservedName(
|
||
Dictionary<string, CharacterCandidate> groups,
|
||
CharacterIndex existingIndex,
|
||
Scene importedScene,
|
||
SceneIntelligenceScene parsed,
|
||
string? name,
|
||
string? role,
|
||
bool mentionedOnly,
|
||
decimal? confidence,
|
||
string? notes,
|
||
IReadOnlyList<SceneCharacter> existingAppearances)
|
||
{
|
||
var cleanName = CleanCharacterName(name);
|
||
if (!IsCharacterNameCandidate(cleanName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
|
||
{
|
||
Name = cleanName,
|
||
RoleInScene = role,
|
||
MentionedOnly = mentionedOnly,
|
||
Confidence = confidence ?? 0.65m,
|
||
Notes = notes
|
||
}, existingAppearances);
|
||
}
|
||
|
||
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(FirstName(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);
|
||
index.Add(FirstName(alias.Alias), character.CharacterID, character.CharacterName);
|
||
}
|
||
}
|
||
|
||
return index;
|
||
}
|
||
|
||
private async Task TryAddAliasAsync(int characterId, string alias, string primaryName)
|
||
{
|
||
var cleanAlias = Clean(alias);
|
||
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var existingAliases = await characters.ListAliasesAsync(characterId);
|
||
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
return;
|
||
}
|
||
|
||
await characters.AddAliasAsync(characterId, cleanAlias, null);
|
||
}
|
||
|
||
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 int? MatchRole(IReadOnlyList<CharacterRoleInSceneType> roleTypes, string? role)
|
||
{
|
||
var value = Clean(role);
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return roleTypes.FirstOrDefault(type =>
|
||
value.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase)
|
||
|| type.TypeName.Contains(value, StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID;
|
||
}
|
||
|
||
private static int? MatchPresence(IReadOnlyList<PresenceType> presenceTypes, bool mentionedOnly)
|
||
{
|
||
var preferred = mentionedOnly ? "Mentioned" : "Present";
|
||
return presenceTypes.FirstOrDefault(type => type.TypeName.Contains(preferred, StringComparison.OrdinalIgnoreCase))?.PresenceTypeID;
|
||
}
|
||
|
||
private static void AddResolvedName(Dictionary<string, int> map, string name, int characterId)
|
||
{
|
||
var clean = Clean(name);
|
||
if (!string.IsNullOrWhiteSpace(clean))
|
||
{
|
||
map[clean] = characterId;
|
||
}
|
||
}
|
||
|
||
private static string? BuildAliasAppearanceNote(string aliasName, string targetName, string? notes)
|
||
{
|
||
var prefix = $"Imported as alias '{aliasName}' of {targetName}.";
|
||
return string.IsNullOrWhiteSpace(notes) ? prefix : $"{prefix} {notes}";
|
||
}
|
||
|
||
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
|
||
{
|
||
var note = Clean(character.Notes);
|
||
if (!string.IsNullOrWhiteSpace(note))
|
||
{
|
||
return $"Scene {scene.SceneNumber:g}: {note}";
|
||
}
|
||
|
||
var summary = Clean(parsed.Summary?.Short);
|
||
return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}";
|
||
}
|
||
|
||
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? FirstName(string name)
|
||
{
|
||
var parts = Clean(name).Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
return parts.Length > 1 ? parts[0] : null;
|
||
}
|
||
|
||
private static bool IsGenericPerson(string name)
|
||
=> GenericPeople.Contains(name) || GenericPeople.Contains(Normalise(name));
|
||
|
||
private static bool IsCharacterEntity(string? entityType)
|
||
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static bool IsVisibleCharacterCandidate(CharacterCandidate candidate)
|
||
{
|
||
var name = CleanCharacterName(candidate.DisplayName);
|
||
if (!IsCharacterNameCandidate(name))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (IsNamedOrTitledPerson(name))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (IsFamilyRoleOnly(name))
|
||
{
|
||
return candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances)
|
||
.Select(appearance => appearance.SceneID)
|
||
.Distinct()
|
||
.Count() >= 2;
|
||
}
|
||
|
||
return !IsGenericGroupReference(name);
|
||
}
|
||
|
||
private static bool IsCharacterNameCandidate(string? name)
|
||
{
|
||
var clean = CleanCharacterName(name);
|
||
if (string.IsNullOrWhiteSpace(clean)
|
||
|| IgnoredNames.Contains(clean)
|
||
|| IsGenericPerson(clean)
|
||
|| NonCharacterNames.Contains(clean))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (!clean.Any(char.IsLetter) || clean.Length < 2)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private static bool IsGenericGroupReference(string? name)
|
||
{
|
||
var clean = CleanCharacterName(name);
|
||
if (string.IsNullOrWhiteSpace(clean))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var withoutParentheses = Clean(ParentheticalSuffix.Replace(clean, string.Empty));
|
||
var normalised = Normalise(withoutParentheses);
|
||
if (GenericPeople.Contains(normalised) || NonCharacterNames.Contains(normalised))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var words = WordsForFiltering(withoutParentheses);
|
||
if (words.Count == 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (LeadingQuantity.IsMatch(withoutParentheses)
|
||
&& words.Any(word => GenericGroupHeads.Contains(word)))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (words.Count > 1
|
||
&& words.Any(word => GenericGroupHeads.Contains(word))
|
||
&& !HasSpecificPossessiveFamilyReference(words))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (words.Count > 1
|
||
&& DeterminerWords.Contains(words[0])
|
||
&& words.Skip(1).Any(word => FamilyRoleNames.Contains(word) || GenericGroupHeads.Contains(word)))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (words.Count > 1
|
||
&& QuantityWords.Contains(words[0])
|
||
&& words.Skip(1).Any(word => GenericGroupHeads.Contains(word)))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return words.All(word => GenericGroupHeads.Contains(word) || QuantityWords.Contains(word));
|
||
}
|
||
|
||
private static bool IsNamedOrTitledPerson(string name)
|
||
{
|
||
var words = WordsForFiltering(name);
|
||
if (words.Count == 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return CharacterTitlePrefixes.Contains(words[0]) && words.Count > 1;
|
||
}
|
||
|
||
private static bool IsFamilyRoleOnly(string name)
|
||
{
|
||
var words = WordsForFiltering(name);
|
||
return words.Count == 1 && FamilyRoleNames.Contains(words[0]);
|
||
}
|
||
|
||
private static bool HasSpecificPossessiveFamilyReference(IReadOnlyList<string> words)
|
||
=> words.Count == 2
|
||
&& FamilyRoleNames.Contains(words[1])
|
||
&& words[0].EndsWith("s", StringComparison.OrdinalIgnoreCase)
|
||
&& !DeterminerWords.Contains(words[0]);
|
||
|
||
private static string ResolveCandidateKey(
|
||
IReadOnlyDictionary<string, CharacterCandidate> groups,
|
||
CharacterIndex existingIndex,
|
||
string name)
|
||
{
|
||
var canonical = CanonicalCharacterKey(name);
|
||
if (groups.ContainsKey(canonical))
|
||
{
|
||
return canonical;
|
||
}
|
||
|
||
var existingMatch = existingIndex.Find(name);
|
||
if (existingMatch is not null)
|
||
{
|
||
var existingKey = CanonicalCharacterKey(existingMatch.CharacterName);
|
||
if (groups.ContainsKey(existingKey))
|
||
{
|
||
return existingKey;
|
||
}
|
||
}
|
||
|
||
var first = FirstNameWithoutTitle(name);
|
||
if (!string.IsNullOrWhiteSpace(first))
|
||
{
|
||
foreach (var candidate in groups.Values)
|
||
{
|
||
if (string.Equals(FirstNameWithoutTitle(candidate.DisplayName), first, StringComparison.OrdinalIgnoreCase)
|
||
&& IsSafeFirstNameMerge(candidate.DisplayName, name))
|
||
{
|
||
return candidate.Key;
|
||
}
|
||
}
|
||
}
|
||
|
||
return canonical;
|
||
}
|
||
|
||
private static bool IsSafeFirstNameMerge(string current, string next)
|
||
{
|
||
var currentWords = WordsWithoutTitles(current);
|
||
var nextWords = WordsWithoutTitles(next);
|
||
return currentWords.Count == 1 || nextWords.Count == 1 || currentWords.SequenceEqual(nextWords, StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static string CanonicalCharacterKey(string name)
|
||
=> Normalise(string.Join(' ', WordsWithoutTitles(CleanCharacterName(name))));
|
||
|
||
private static string? FirstNameWithoutTitle(string? name)
|
||
=> WordsWithoutTitles(name).FirstOrDefault();
|
||
|
||
private static List<string> WordsWithoutTitles(string? name)
|
||
{
|
||
var words = CleanCharacterName(name)
|
||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(word => word.Trim('.', ',', ';', ':', '!', '?', '"', '\''))
|
||
.Where(word => !string.IsNullOrWhiteSpace(word))
|
||
.ToList();
|
||
|
||
if (words.Count > 1 && IsMergeableTitle(words[0]))
|
||
{
|
||
words.RemoveAt(0);
|
||
}
|
||
|
||
return words;
|
||
}
|
||
|
||
private static List<string> WordsForFiltering(string? name)
|
||
=> CleanCharacterName(name)
|
||
.Replace("/", " ", StringComparison.Ordinal)
|
||
.Replace("-", " ", StringComparison.Ordinal)
|
||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(word => word.Trim('.', ',', ';', ':', '!', '?', '"', '\'', '(', ')').ToLowerInvariant())
|
||
.Where(word => !string.IsNullOrWhiteSpace(word))
|
||
.ToList();
|
||
|
||
private static bool IsMergeableTitle(string value)
|
||
=> value.Equals("instructor", StringComparison.OrdinalIgnoreCase)
|
||
|| value.Equals("detective", StringComparison.OrdinalIgnoreCase)
|
||
|| value.Equals("sergeant", StringComparison.OrdinalIgnoreCase)
|
||
|| value.Equals("ds", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static string CleanCharacterName(string? value)
|
||
{
|
||
var clean = Clean(value)
|
||
.Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
|
||
|
||
if (clean.EndsWith("'s", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
clean = clean[..^2];
|
||
}
|
||
else if (clean.EndsWith("’s", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
clean = clean[..^2];
|
||
}
|
||
|
||
return Clean(clean.Replace("Mrs.", "Mrs", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("Mr.", "Mr", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("Ms.", "Ms", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("Dr.", "Dr", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
private static string Normalise(string? value)
|
||
=> Clean(value).ToLowerInvariant();
|
||
|
||
private static string Clean(string? value)
|
||
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||
|
||
private static HashSet<string> CreateSet(params string[] values)
|
||
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
|
||
private sealed class CharacterIndex
|
||
{
|
||
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
public void Add(string? name, int characterId, string characterName)
|
||
{
|
||
var key = Normalise(name);
|
||
if (!string.IsNullOrWhiteSpace(key))
|
||
{
|
||
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
|
||
}
|
||
}
|
||
|
||
public CharacterMatch? Find(string name)
|
||
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
|
||
}
|
||
|
||
private sealed record CharacterMatch(int CharacterID, string CharacterName);
|
||
|
||
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
|
||
{
|
||
public string Key { get; } = key;
|
||
public string DisplayName { get; } = displayName;
|
||
public int? ExistingCharacterID { get; } = existingCharacterId;
|
||
public string? ExistingCharacterName { get; } = existingCharacterName;
|
||
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||
public List<CharacterAppearanceImport> PresentAppearances { get; } = [];
|
||
public List<CharacterAppearanceImport> MentionedOnlyAppearances { get; } = [];
|
||
public decimal? Confidence { get; set; }
|
||
public string? Description { get; set; }
|
||
public string? FirstAppearanceNote { get; set; }
|
||
}
|
||
|
||
private sealed record CharacterAppearanceImport(
|
||
int SceneID,
|
||
decimal SceneNumber,
|
||
string SceneTitle,
|
||
string? RoleInScene,
|
||
bool MentionedOnly,
|
||
string? Notes,
|
||
decimal? Confidence,
|
||
bool AlreadyLinked);
|
||
|
||
private sealed record ImportedSceneAnalysis(int SceneID, SceneIntelligenceScene Parsed);
|
||
|
||
private sealed record CharacterCandidateData(
|
||
bool HasCommittedScenes,
|
||
int AlreadyLinkedCount,
|
||
IReadOnlyList<CharacterCandidate> Candidates,
|
||
IReadOnlyList<ImportedSceneAnalysis> SceneAnalyses);
|
||
}
|