using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface IWordCompanionService { Task ListProjectsAsync(); Task ListBooksAsync(int projectId); Task CreateBookAsync(int projectId, WordCompanionCreateBookRequest request); Task GetBookStructureAsync(int bookId); Task ListChaptersAsync(int bookId); Task ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request); Task GetSceneCompanionAsync(int sceneId); Task CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request); Task CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request); Task SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request); Task SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request); Task UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request); Task UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request); Task UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request); Task ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request); Task ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid); Task CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request); Task ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid); Task CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request); Task ListRuntimeLocationAliasesAsync(int projectId, Guid documentGuid); Task CreateRuntimeLocationSuggestionsAsync(WordCompanionRuntimeLocationSuggestionsRequest request); Task GetRuntimeBookAsync(int bookId, Guid documentGuid); Task GetManuscriptForBookAsync(int bookId); Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request); Task UnlinkManuscriptAsync(WordCompanionManuscriptUnlinkRequest request); Task AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request); Task ImportManuscriptAsync(WordCompanionManuscriptImportRequest request); Task DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request); Task CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request); } public sealed class WordCompanionService( IWordCompanionRepository repository, IManuscriptDocumentRepository manuscriptDocuments, ICurrentUserService currentUser) : IWordCompanionService { private const int CharacterDiscoveryMinimumMentions = 2; private const int CharacterDiscoverySuggestionLimit = 50; private const int CharacterDiscoveryExcludedLimit = 30; private static readonly Regex CharacterCandidateRegex = new( @"(? CharacterDiscoveryStopWords = new(StringComparer.OrdinalIgnoreCase) { "A", "An", "As", "At", "By", "He", "Her", "Hers", "Him", "His", "I", "If", "In", "It", "Its", "Me", "Mine", "My", "Of", "On", "Our", "Ours", "She", "The", "Their", "Theirs", "They", "Them", "To", "Us", "We", "You", "Your", "Yours", "What", "When", "Where", "Why", "Who", "How", "Which", "Whose", "Whom", "Yes", "No", "Yeah", "Yep", "Nope", "Okay", "OK", "Ok", "Well", "Right", "There", "Here", "This", "That", "These", "Those", "Then", "Now", "Not", "Just", "Only", "Very", "Really", "Maybe", "Perhaps", "Probably", "Are", "Is", "Was", "Were", "Be", "Been", "Being", "Am", "Can", "Could", "Should", "Would", "Will", "Won", "Shall", "May", "Might", "Must", "For", "From", "With", "Without", "Into", "Onto", "Over", "Under", "Between", "Before", "After", "During", "Through", "Around", "About", "And", "But", "Or", "So", "Because", "Though", "Although", "One", "Two", "Three", "First", "Second", "Third", "Come", "Go", "Get", "Got", "Let", "Look", "See", "Tell", "Ask", "Asked", "Said", "Say", "Think", "Thought", "Know", "Knew", "Want", "Wanted", "Need", "Needed", "Please", "Thank", "Thanks", "Sorry", "Hello", "Hi", "Hey", "Goodbye", "Bye", "Chapter", "Scene", "Book", "Part", "Page", "Prologue", "Epilogue", "Volume", "Act", "Christmas", "Easter", "Morning", "Afternoon", "Evening", "Night", "Today", "Tomorrow", "Yesterday", "Tonight", "Tomorrow", "Yesterday", "God", "Something", "Anything", "Nothing", "Everything", "Everyone", "Somebody", "Someone", "Anybody", "Nobody", "People", "Person", "Man", "Woman", "Boy", "Girl", "Did", "Do", "Does", "Done", "Had", "Has", "Have", "Having", "Even", "All", "Fine", "Great", "Sure", "Course", "Thing", "Things", "Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New", "Somewhere", "Coke", "Like", "Don", "Isn" }; private static readonly HashSet CharacterDiscoveryRelationshipTitles = new(StringComparer.OrdinalIgnoreCase) { "Mum", "Dad", "Mother", "Father", "Gran", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Brother", "Sister", "Aunt", "Auntie", "Uncle", "Cousin" }; private static readonly HashSet CharacterDiscoveryTemporalWords = new(StringComparer.OrdinalIgnoreCase) { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", "Spring", "Summer", "Autumn", "Fall", "Winter" }; private static readonly HashSet CharacterDiscoveryNameLikeWords = new(StringComparer.OrdinalIgnoreCase) { "Grace", "Jack", "Rose", "May", "Will", "Mark", "Hope", "Faith", "Pat", "Rob", "Bill", "Harry", "November", "Autumn", "Rain" }; private static readonly HashSet CharacterDiscoveryHonorifics = new(StringComparer.OrdinalIgnoreCase) { "Mr", "Mrs", "Ms", "Miss", "Dr", "Rev", "Fr", "Sir", "Lady", "Lord", "DS", "DI", "DC", "PC", "Professor", "Aunt", "Uncle" }; private static readonly HashSet CharacterDiscoveryDialogueTags = new(StringComparer.OrdinalIgnoreCase) { "said", "asked", "replied", "answered", "whispered", "shouted", "called", "muttered", "cried", "snapped", "sighed", "laughed", "continued", "began", "told", "yelled" }; private static readonly HashSet CharacterDiscoveryHumanActionVerbs = new(StringComparer.OrdinalIgnoreCase) { "walked", "turned", "smiled", "looked", "stared", "nodded", "sat", "stood", "stepped", "ran", "came", "went", "opened", "closed", "held", "took", "put", "gave", "grabbed", "shrugged", "frowned", "laughed", "sighed", "watched", "waited", "leaned", "knelt", "waved", "pointed", "followed", "paused", "breathed" }; private static readonly HashSet CharacterDiscoveryLocationIndicators = new(StringComparer.OrdinalIgnoreCase) { "Road", "Lane", "Street", "Close", "Avenue", "Drive", "Way", "Hill", "Hospital", "Inn", "Church", "School", "House", "Farm", "Hall", "Court", "Station", "Hotel", "Shop", "Cafe", "Café", "Pub", "Park", "Gardens" }; public async Task ListProjectsAsync() { var userId = RequireUserId(); return new WordCompanionProjectsResponse { Projects = await repository.ListProjectsAsync(userId) }; } public async Task ListBooksAsync(int projectId) { if (projectId <= 0) { return null; } var books = await repository.ListBooksAsync(projectId, RequireUserId()); return new WordCompanionBooksResponse { Books = books }; } public Task CreateBookAsync(int projectId, WordCompanionCreateBookRequest request) { if (projectId <= 0 || string.IsNullOrWhiteSpace(request.Title)) { return Task.FromResult(null); } request.Title = request.Title.Trim(); return repository.CreateBookAsync(projectId, RequireUserId(), request); } public Task GetBookStructureAsync(int bookId) { return bookId <= 0 ? Task.FromResult(null) : repository.GetBookStructureAsync(bookId, RequireUserId()); } public async Task ListChaptersAsync(int bookId) { if (bookId <= 0) { return null; } var chapters = await repository.ListChaptersAsync(bookId, RequireUserId()); return new WordCompanionChaptersResponse { Chapters = chapters }; } public Task ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request) { if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle)) { return Task.FromResult(null); } return ResolveSceneCoreAsync(bookId, request); } public Task GetSceneCompanionAsync(int sceneId) { return sceneId <= 0 ? Task.FromResult(null) : repository.GetSceneCompanionAsync(sceneId, RequireUserId()); } public Task CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request) { if (bookId <= 0 || string.IsNullOrWhiteSpace(request.Title) || request.SortOrder is <= 0) { return Task.FromResult(null); } request.Title = request.Title.Trim(); return repository.CreateChapterAsync(bookId, RequireUserId(), request); } public Task CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request) { if (bookId <= 0 || chapterId <= 0 || string.IsNullOrWhiteSpace(request.SceneTitle)) { return Task.FromResult(null); } request.SceneTitle = request.SceneTitle.Trim(); return repository.CreateSceneAsync(bookId, chapterId, RequireUserId(), request); } public Task SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request) { if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle) || request.ActualWords < 0 || request.ChapterSortOrder is <= 0 || request.SceneSortOrder is <= 0) { return Task.FromResult(null); } request.ChapterTitle = request.ChapterTitle.Trim(); request.SceneTitle = request.SceneTitle.Trim(); return repository.SyncSceneAsync(bookId, RequireUserId(), request); } public Task SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request) { if (bookId <= 0 || request.Chapters.Any(chapter => string.IsNullOrWhiteSpace(chapter.Title) || chapter.WordCount < 0 || chapter.SortOrder is <= 0 || chapter.Scenes.Any(scene => string.IsNullOrWhiteSpace(scene.Title) || scene.WordCount < 0 || scene.SortOrder is <= 0))) { return Task.FromResult(null); } foreach (var chapter in request.Chapters) { chapter.Title = chapter.Title?.Trim(); foreach (var scene in chapter.Scenes) { scene.Title = scene.Title?.Trim(); } } return repository.SyncManuscriptAsync(bookId, RequireUserId(), request); } public Task UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request) { if (sceneId <= 0 || request.PrimaryLocationId is <= 0 || HasInvalidIds(request.CharacterIds) || HasInvalidIds(request.AssetIds) || HasInvalidIds(request.LocationIds)) { return Task.FromResult(null); } return repository.UpdateSceneLinksAsync(sceneId, RequireUserId(), request); } public Task UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request) { var actualWords = request.ActualWordCount ?? request.ActualWords; return sceneId <= 0 || actualWords is null or < 0 ? Task.FromResult(null) : repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn); } public Task UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request) { return request.DocumentGuid == Guid.Empty || request.BookId <= 0 || request.ChapterId <= 0 || request.SceneId <= 0 || request.WordCount < 0 ? Task.FromResult(null) : repository.UpdateRuntimeSceneWordCountAsync(RequireUserId(), request); } public Task ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request) { return request.DocumentGuid == Guid.Empty || request.ProjectId <= 0 || request.BookId <= 0 || request.ChapterId <= 0 || request.SceneId <= 0 ? Task.FromResult(null) : repository.ValidateRuntimeCurrentSceneAsync(RequireUserId(), request); } public async Task ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid) { if (projectId <= 0 || documentGuid == Guid.Empty) { return null; } var aliases = await repository.ListRuntimeCharacterAliasesAsync(RequireUserId(), projectId, documentGuid); return new WordCompanionRuntimeCharacterAliasResponse { ProjectId = projectId, Characters = aliases }; } public Task CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request) { request.CharacterIds = request.CharacterIds .Where(id => id > 0) .Distinct() .Take(CharacterDiscoverySuggestionLimit) .ToList(); return request.DocumentGuid == Guid.Empty || request.SceneId <= 0 || request.CharacterIds.Count == 0 ? Task.FromResult(null) : repository.CreateRuntimeCharacterSuggestionsAsync(RequireUserId(), request); } public async Task ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid) { if (projectId <= 0 || documentGuid == Guid.Empty) { return null; } var aliases = await repository.ListRuntimeAssetAliasesAsync(RequireUserId(), projectId, documentGuid); return new WordCompanionRuntimeAssetAliasResponse { ProjectId = projectId, Assets = aliases }; } public Task CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request) { request.AssetIds = request.AssetIds .Where(id => id > 0) .Distinct() .Take(CharacterDiscoverySuggestionLimit) .ToList(); return request.DocumentGuid == Guid.Empty || request.SceneId <= 0 || request.AssetIds.Count == 0 ? Task.FromResult(null) : repository.CreateRuntimeAssetSuggestionsAsync(RequireUserId(), request); } public async Task ListRuntimeLocationAliasesAsync(int projectId, Guid documentGuid) { if (projectId <= 0 || documentGuid == Guid.Empty) { return null; } var aliases = await repository.ListRuntimeLocationAliasesAsync(RequireUserId(), projectId, documentGuid); return new WordCompanionRuntimeLocationAliasResponse { ProjectId = projectId, Locations = aliases }; } public Task CreateRuntimeLocationSuggestionsAsync(WordCompanionRuntimeLocationSuggestionsRequest request) { request.LocationIds = request.LocationIds .Where(id => id > 0) .Distinct() .Take(CharacterDiscoverySuggestionLimit) .ToList(); return request.DocumentGuid == Guid.Empty || request.SceneId <= 0 || request.LocationIds.Count == 0 ? Task.FromResult(null) : repository.CreateRuntimeLocationSuggestionsAsync(RequireUserId(), request); } public async Task GetRuntimeBookAsync(int bookId, Guid documentGuid) { if (bookId <= 0 || documentGuid == Guid.Empty) { return null; } var userId = RequireUserId(); var document = await manuscriptDocuments.GetByGuidAsync(documentGuid, userId); if (document is null || document.BookID != bookId || !document.IsActive) { return null; } await manuscriptDocuments.UpdateLastOpenedAsync(document.ManuscriptDocumentID, userId); document = await manuscriptDocuments.GetByGuidAsync(documentGuid, userId) ?? document; var projects = await repository.ListProjectsAsync(userId); var project = projects.FirstOrDefault(item => item.ProjectId == document.ProjectID); if (project is null) { return null; } var books = await repository.ListBooksAsync(document.ProjectID, userId); var book = books.FirstOrDefault(item => item.BookId == document.BookID); if (book is null) { return null; } return new WordCompanionRuntimeBookResponse { ProjectId = project.ProjectId, ProjectTitle = project.Title, BookId = book.BookId, BookTitle = book.Title, BookSubtitle = book.Subtitle, UsesExplicitScenes = book.UsesExplicitScenes, LastSyncUtc = document.LastSyncUtc, ManuscriptDocument = ToDto(document) }; } public async Task GetManuscriptForBookAsync(int bookId) { if (bookId <= 0) { return null; } var document = await manuscriptDocuments.GetByBookAsync(bookId, RequireUserId()); return new WordCompanionManuscriptBookResponse { BookId = bookId, ManuscriptDocument = document is null ? null : ToDto(document) }; } public async Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request) { if (request.BookId <= 0) { return null; } var documentGuid = request.DocumentGuid.GetValueOrDefault(); if (documentGuid == Guid.Empty) { documentGuid = Guid.NewGuid(); } ManuscriptDocumentModel? document; try { document = await manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId()); } catch (InvalidOperationException) { return null; } return document is null ? null : new WordCompanionManuscriptLinkResponse { BookId = document.BookID, ManuscriptDocument = ToDto(document), Message = "Manuscript linked." }; } public async Task UnlinkManuscriptAsync(WordCompanionManuscriptUnlinkRequest request) { if (request.BookId <= 0) { return null; } var result = await manuscriptDocuments.UnlinkBookAsync(request.BookId, request.DocumentGuid, RequireUserId()); return result is null ? null : new WordCompanionManuscriptUnlinkResponse { BookId = result.BookID, ManuscriptDocumentId = result.ManuscriptDocumentID, ChaptersUnlinked = result.ChaptersUnlinked, ScenesUnlinked = result.ScenesUnlinked, Message = result.Message }; } public Task AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request) { if (request.ProjectId <= 0 || request.BookId <= 0 || request.ChapterCount < 0 || request.SceneCount < 0 || request.WordCount < 0) { return Task.FromResult(null); } return repository.AnalyseManuscriptAsync(RequireUserId(), request); } public Task ImportManuscriptAsync(WordCompanionManuscriptImportRequest request) { if (request.ProjectId <= 0 || request.BookId <= 0 || request.BindingVersion <= 0 || request.Chapters.Count == 0 || request.Chapters.Any(chapter => string.IsNullOrWhiteSpace(chapter.Title) || chapter.SortOrder is <= 0 || chapter.WordCount < 0 || chapter.Scenes.Count == 0 || chapter.Scenes.Any(scene => string.IsNullOrWhiteSpace(scene.Title) || scene.SortOrder is <= 0 || scene.WordCount < 0))) { return Task.FromResult(null); } request.DocumentGuid = request.DocumentGuid.GetValueOrDefault() == Guid.Empty ? Guid.NewGuid() : request.DocumentGuid; foreach (var chapter in request.Chapters) { chapter.Title = chapter.Title?.Trim(); foreach (var scene in chapter.Scenes) { scene.Title = scene.Title?.Trim(); } } return repository.ImportManuscriptAsync(RequireUserId(), request); } public async Task DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request) { if (request.ProjectId <= 0) { return null; } var userId = RequireUserId(); if (!await repository.HasProjectAccessAsync(request.ProjectId, userId)) { return null; } var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty, request.IncludeExcluded); return new WordCompanionDiscoverCharactersResponse { ProjectId = request.ProjectId, Candidates = candidates }; } public Task CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request) { if (request.ProjectId <= 0 || request.CandidateNames.Count == 0) { return Task.FromResult(null); } request.CandidateNames = request.CandidateNames .Select(NormalizeCandidateName) .Where(name => !string.IsNullOrWhiteSpace(name) && name.Length <= 200) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(CharacterDiscoverySuggestionLimit) .ToList(); return request.CandidateNames.Count == 0 ? Task.FromResult(null) : repository.CreateDiscoveredCharactersAsync(RequireUserId(), request); } private async Task ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request) { return await repository.ResolveSceneAsync(bookId, RequireUserId(), request.ChapterTitle!.Trim(), request.SceneTitle!.Trim()); } private int RequireUserId() { return currentUser.UserId ?? throw new InvalidOperationException("An authenticated user is required."); } private static bool HasInvalidIds(IEnumerable ids) => ids.Any(id => id <= 0); private static IReadOnlyList DiscoverCharacterCandidates(string manuscriptText, bool includeExcluded = false) { if (string.IsNullOrWhiteSpace(manuscriptText)) { return []; } manuscriptText = NormalizeApostrophes(manuscriptText); var candidates = new Dictionary(StringComparer.Ordinal); foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText)) { var candidate = NormalizeCandidateName(match.Value); if (string.IsNullOrWhiteSpace(candidate)) { continue; } if (!candidates.TryGetValue(candidate, out var evidence)) { evidence = new CharacterCandidateEvidence(candidate); candidates[candidate] = evidence; } evidence.MentionCount += 1; if (AppearsAtSentenceStart(manuscriptText, match.Index)) { evidence.SentenceStartMentions += 1; } if (HasDialogueTagEvidence(manuscriptText, match.Index, match.Length)) { evidence.DialogueEvidenceCount += 1; } if (HasPossessiveEvidence(manuscriptText, match.Index, match.Length)) { evidence.PossessiveEvidenceCount += 1; } if (HasHumanActionEvidence(manuscriptText, match.Index, match.Length)) { evidence.HumanActionEvidenceCount += 1; } } var builtCandidates = candidates.Values .Select(BuildCandidateDto) .Where(item => item is not null) .Select(item => item!) .ToList(); var visible = builtCandidates .Where(item => !string.Equals(item.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(item => CategorySortValue(item.Category)) .ThenByDescending(item => item.QualityScore) .ThenByDescending(item => ConfidenceSortValue(item.Confidence)) .ThenByDescending(item => item.MentionCount) .ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase) .Take(CharacterDiscoverySuggestionLimit) .ToList(); if (includeExcluded) { visible.AddRange(builtCandidates .Where(item => string.Equals(item.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(item => item.MentionCount) .ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase) .Take(CharacterDiscoveryExcludedLimit)); } return visible; } private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence) { if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200) { return null; } var words = evidence.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries); var first = words.Length > 0 ? TrimHonorificPunctuation(words[0]) : string.Empty; if (words.Length == 0) { return null; } var hasHonorific = words.Length > 1 && CharacterDiscoveryHonorifics.Contains(first); var hasMultiWordName = words.Length > 1; var hasNameLikeWord = words.Any(word => CharacterDiscoveryNameLikeWords.Contains(TrimHonorificPunctuation(word))); var hasTemporalWord = words.Any(word => CharacterDiscoveryTemporalWords.Contains(TrimHonorificPunctuation(word))); var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.Contains(TrimHonorificPunctuation(word))); var hasStopWord = words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word))); var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first); var nonSentenceStartMentions = evidence.MentionCount - evidence.SentenceStartMentions; var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m); var score = 0; if (evidence.MentionCount >= CharacterDiscoveryMinimumMentions) { score += 1; } if (evidence.MentionCount >= 10) { score += 1; } if (evidence.MentionCount >= 25) { score += 1; } if (nonSentenceStartMentions > 0) { score += 2; } if (evidence.DialogueEvidenceCount > 0) { score += 5; } if (hasMultiWordName) { score += 5; } if (hasHonorific) { score += 4; } if (evidence.PossessiveEvidenceCount > 0) { score += 2; } if (evidence.HumanActionEvidenceCount > 0) { score += 2; } if (hasNameLikeWord) { score += 3; } if (hasTemporalWord) { score -= 1; } if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific) { score -= 2; } if (mostlySentenceStart) { score -= 2; } if (!hasMultiWordName && evidence.DialogueEvidenceCount == 0 && evidence.PossessiveEvidenceCount == 0 && evidence.HumanActionEvidenceCount == 0 && nonSentenceStartMentions == 0) { score -= 1; } if (evidence.MentionCount < CharacterDiscoveryMinimumMentions) { score -= 2; } if (first.Length <= 2 && !hasHonorific) { score -= 3; } var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence); var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low"; if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific) { confidence = score >= 4 ? "Medium" : "Low"; } if (string.Equals(category, "Excluded", StringComparison.Ordinal)) { confidence = "Low"; } return new WordCompanionCharacterCandidateDto { Text = evidence.Text, MentionCount = evidence.MentionCount, Confidence = confidence, QualityScore = score, Category = category, Reason = BuildCandidateReason(evidence, hasStopWord, isRelationshipTitle, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart) }; } private static string CandidateCategory( int score, bool hasStopWord, bool isRelationshipTitle, bool hasTemporalWord, bool hasLocationIndicator, CharacterCandidateEvidence evidence) { if (hasStopWord) { return "Excluded"; } if (isRelationshipTitle) { return "RelationshipTitle"; } if (hasTemporalWord && evidence.DialogueEvidenceCount == 0 && evidence.PossessiveEvidenceCount == 0) { return "Excluded"; } if (hasLocationIndicator && score < 4) { return "Excluded"; } if (evidence.MentionCount < CharacterDiscoveryMinimumMentions && evidence.DialogueEvidenceCount == 0) { return "Excluded"; } return score >= 4 ? "ProbableCharacter" : "PossibleCharacter"; } private static string BuildCandidateReason( CharacterCandidateEvidence evidence, bool hasStopWord, bool isRelationshipTitle, bool hasHonorific, bool hasMultiWordName, bool hasTemporalWord, bool hasLocationIndicator, bool mostlySentenceStart) { if (hasStopWord) { return "Excluded word"; } if (isRelationshipTitle) { return "Relationship title"; } if (evidence.DialogueEvidenceCount > 0) { return "Dialogue evidence"; } if (evidence.PossessiveEvidenceCount > 0) { return "Possessive use"; } if (evidence.HumanActionEvidenceCount > 0) { return "Human action nearby"; } if (hasHonorific) { return "Title or honorific"; } if (hasMultiWordName) { if (hasLocationIndicator) { return "Possible location"; } return "Proper-name phrase"; } if (hasTemporalWord) { return "Possible name or temporal word"; } if (mostlySentenceStart) { return "Mostly sentence starts"; } return evidence.MentionCount >= 10 ? "Frequent possible name" : "Possible name"; } private static int ConfidenceSortValue(string confidence) { return string.Equals(confidence, "High", StringComparison.OrdinalIgnoreCase) ? 3 : string.Equals(confidence, "Medium", StringComparison.OrdinalIgnoreCase) ? 2 : 1; } private static int CategorySortValue(string category) { return string.Equals(category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase) ? 4 : string.Equals(category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase) ? 3 : string.Equals(category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase) ? 2 : 1; } private static bool AppearsAtSentenceStart(string manuscriptText, int matchIndex) { for (var i = matchIndex - 1; i >= 0; i--) { var character = manuscriptText[i]; if (char.IsWhiteSpace(character)) { if (character is '\n' or '\r') { return true; } continue; } if (character is '"' or '\'' or '’' or '“' or '”' or '(' or '[') { continue; } return character is '.' or '!' or '?' or ':' or ';'; } return true; } private static bool HasDialogueTagEvidence(string manuscriptText, int matchIndex, int matchLength) { var before = manuscriptText[Math.Max(0, matchIndex - 48)..matchIndex]; var afterStart = matchIndex + matchLength; var after = manuscriptText[afterStart..Math.Min(manuscriptText.Length, afterStart + 48)]; return HasTagAfterCandidate(after) || HasTagBeforeCandidate(before); } private static bool HasPossessiveEvidence(string manuscriptText, int matchIndex, int matchLength) { var afterStart = matchIndex + matchLength; return afterStart + 1 < manuscriptText.Length && manuscriptText[afterStart] == '\'' && char.ToLowerInvariant(manuscriptText[afterStart + 1]) == 's'; } private static bool HasHumanActionEvidence(string manuscriptText, int matchIndex, int matchLength) { var afterStart = matchIndex + matchLength; var after = manuscriptText[afterStart..Math.Min(manuscriptText.Length, afterStart + 36)]; var match = Regex.Match(after, @"^\s+(?[a-z]+)\b", RegexOptions.IgnoreCase); return match.Success && CharacterDiscoveryHumanActionVerbs.Contains(match.Groups["verb"].Value); } private static bool HasTagAfterCandidate(string textAfterCandidate) { var match = Regex.Match(textAfterCandidate, @"^\s*,?\s*(?[a-z]+)\b", RegexOptions.IgnoreCase); return match.Success && CharacterDiscoveryDialogueTags.Contains(match.Groups["tag"].Value); } private static bool HasTagBeforeCandidate(string textBeforeCandidate) { var match = Regex.Match(textBeforeCandidate, @"\b(?[a-z]+)\s*,?\s*[""'\u2019\u201d]?\s*$", RegexOptions.IgnoreCase); return match.Success && CharacterDiscoveryDialogueTags.Contains(match.Groups["tag"].Value); } private static string NormalizeCandidateName(string candidate) { return Regex.Replace(NormalizeApostrophes(candidate), @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\''); } private static string NormalizeApostrophes(string? value) { return (value ?? string.Empty).Replace('’', '\'').Replace('‘', '\''); } private static string TrimHonorificPunctuation(string word) { return (word ?? string.Empty).Trim().TrimEnd('.'); } private sealed class CharacterCandidateEvidence(string text) { public string Text { get; } = text; public int MentionCount { get; set; } public int SentenceStartMentions { get; set; } public int DialogueEvidenceCount { get; set; } public int PossessiveEvidenceCount { get; set; } public int HumanActionEvidenceCount { get; set; } } private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new() { ManuscriptDocumentId = document.ManuscriptDocumentID, BookId = document.BookID, ProjectId = document.ProjectID, DocumentGuid = document.DocumentGuid, BindingVersion = document.BindingVersion, CreatedUtc = document.CreatedUtc, LastSyncUtc = document.LastSyncUtc, LastOpenedUtc = document.LastOpenedUtc, IsActive = document.IsActive }; }