PlotDirector/PlotLine/Services/WordCompanionService.cs

965 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IWordCompanionService
{
Task<WordCompanionProjectsResponse> ListProjectsAsync();
Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId);
Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, WordCompanionCreateBookRequest request);
Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId);
Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId);
Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request);
Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId);
Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request);
Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request);
Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request);
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request);
Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request);
Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid);
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request);
Task<WordCompanionRuntimeAssetAliasResponse?> ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid);
Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request);
Task<WordCompanionRuntimeLocationAliasResponse?> ListRuntimeLocationAliasesAsync(int projectId, Guid documentGuid);
Task<WordCompanionRuntimeLocationSuggestionsResponse?> CreateRuntimeLocationSuggestionsAsync(WordCompanionRuntimeLocationSuggestionsRequest request);
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
Task<WordCompanionManuscriptUnlinkResponse?> UnlinkManuscriptAsync(WordCompanionManuscriptUnlinkRequest request);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request);
Task<WordCompanionDiscoverCharactersResponse?> DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request);
Task<WordCompanionCreateDiscoveredCharactersResponse?> 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(
@"(?<!['\p{L}])(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC|Professor|Aunt|Uncle)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?){0,2}(?=(?:[']s)?(?!\p{L})|(?!(?:[']))[^\p{L}]|$)",
RegexOptions.Compiled);
private static readonly HashSet<string> 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<string> CharacterDiscoveryRelationshipTitles = new(StringComparer.OrdinalIgnoreCase)
{
"Mum", "Dad", "Mother", "Father", "Gran", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Brother",
"Sister", "Aunt", "Auntie", "Uncle", "Cousin"
};
private static readonly HashSet<string> 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<string> CharacterDiscoveryNameLikeWords = new(StringComparer.OrdinalIgnoreCase)
{
"Grace", "Jack", "Rose", "May", "Will", "Mark", "Hope", "Faith", "Pat", "Rob", "Bill", "Harry",
"November", "Autumn", "Rain"
};
private static readonly HashSet<string> 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<string> CharacterDiscoveryDialogueTags = new(StringComparer.OrdinalIgnoreCase)
{
"said", "asked", "replied", "answered", "whispered", "shouted", "called", "muttered", "cried", "snapped",
"sighed", "laughed", "continued", "began", "told", "yelled"
};
private static readonly HashSet<string> 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<string> 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<WordCompanionProjectsResponse> ListProjectsAsync()
{
var userId = RequireUserId();
return new WordCompanionProjectsResponse { Projects = await repository.ListProjectsAsync(userId) };
}
public async Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId)
{
if (projectId <= 0)
{
return null;
}
var books = await repository.ListBooksAsync(projectId, RequireUserId());
return new WordCompanionBooksResponse { Books = books };
}
public Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, WordCompanionCreateBookRequest request)
{
if (projectId <= 0 || string.IsNullOrWhiteSpace(request.Title))
{
return Task.FromResult<WordCompanionCreateBookResponse?>(null);
}
request.Title = request.Title.Trim();
return repository.CreateBookAsync(projectId, RequireUserId(), request);
}
public Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId)
{
return bookId <= 0 ? Task.FromResult<WordCompanionBookStructureResponse?>(null) : repository.GetBookStructureAsync(bookId, RequireUserId());
}
public async Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId)
{
if (bookId <= 0)
{
return null;
}
var chapters = await repository.ListChaptersAsync(bookId, RequireUserId());
return new WordCompanionChaptersResponse { Chapters = chapters };
}
public Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request)
{
if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle))
{
return Task.FromResult<WordCompanionResolveSceneResponse?>(null);
}
return ResolveSceneCoreAsync(bookId, request);
}
public Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId)
{
return sceneId <= 0 ? Task.FromResult<WordCompanionSceneCompanionResponse?>(null) : repository.GetSceneCompanionAsync(sceneId, RequireUserId());
}
public Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request)
{
if (bookId <= 0 || string.IsNullOrWhiteSpace(request.Title) || request.SortOrder is <= 0)
{
return Task.FromResult<WordCompanionCreateChapterResponse?>(null);
}
request.Title = request.Title.Trim();
return repository.CreateChapterAsync(bookId, RequireUserId(), request);
}
public Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request)
{
if (bookId <= 0 || chapterId <= 0 || string.IsNullOrWhiteSpace(request.SceneTitle))
{
return Task.FromResult<WordCompanionCreateSceneResponse?>(null);
}
request.SceneTitle = request.SceneTitle.Trim();
return repository.CreateSceneAsync(bookId, chapterId, RequireUserId(), request);
}
public Task<WordCompanionSyncSceneResponse?> 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<WordCompanionSyncSceneResponse?>(null);
}
request.ChapterTitle = request.ChapterTitle.Trim();
request.SceneTitle = request.SceneTitle.Trim();
return repository.SyncSceneAsync(bookId, RequireUserId(), request);
}
public Task<WordCompanionSyncManuscriptResponse?> 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<WordCompanionSyncManuscriptResponse?>(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<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request)
{
if (sceneId <= 0
|| request.PrimaryLocationId is <= 0
|| HasInvalidIds(request.CharacterIds)
|| HasInvalidIds(request.AssetIds)
|| HasInvalidIds(request.LocationIds))
{
return Task.FromResult<WordCompanionUpdateSceneLinksResponse?>(null);
}
return repository.UpdateSceneLinksAsync(sceneId, RequireUserId(), request);
}
public Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request)
{
var actualWords = request.ActualWordCount ?? request.ActualWords;
return sceneId <= 0 || actualWords is null or < 0
? Task.FromResult<WordCompanionUpdateWordCountResponse?>(null)
: repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn);
}
public Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request)
{
return request.DocumentGuid == Guid.Empty
|| request.BookId <= 0
|| request.ChapterId <= 0
|| request.SceneId <= 0
|| request.WordCount < 0
? Task.FromResult<WordCompanionUpdateWordCountResponse?>(null)
: repository.UpdateRuntimeSceneWordCountAsync(RequireUserId(), request);
}
public Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request)
{
return request.DocumentGuid == Guid.Empty
|| request.ProjectId <= 0
|| request.BookId <= 0
|| request.ChapterId <= 0
|| request.SceneId <= 0
? Task.FromResult<WordCompanionRuntimeCurrentSceneResponse?>(null)
: repository.ValidateRuntimeCurrentSceneAsync(RequireUserId(), request);
}
public async Task<WordCompanionRuntimeCharacterAliasResponse?> 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<WordCompanionRuntimeCharacterSuggestionsResponse?> 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<WordCompanionRuntimeCharacterSuggestionsResponse?>(null)
: repository.CreateRuntimeCharacterSuggestionsAsync(RequireUserId(), request);
}
public async Task<WordCompanionRuntimeAssetAliasResponse?> 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<WordCompanionRuntimeAssetSuggestionsResponse?> 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<WordCompanionRuntimeAssetSuggestionsResponse?>(null)
: repository.CreateRuntimeAssetSuggestionsAsync(RequireUserId(), request);
}
public async Task<WordCompanionRuntimeLocationAliasResponse?> 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<WordCompanionRuntimeLocationSuggestionsResponse?> 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<WordCompanionRuntimeLocationSuggestionsResponse?>(null)
: repository.CreateRuntimeLocationSuggestionsAsync(RequireUserId(), request);
}
public async Task<WordCompanionRuntimeBookResponse?> 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<WordCompanionManuscriptBookResponse?> 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<WordCompanionManuscriptLinkResponse?> 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<WordCompanionManuscriptUnlinkResponse?> 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<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request)
{
if (request.ProjectId <= 0
|| request.BookId <= 0
|| request.ChapterCount < 0
|| request.SceneCount < 0
|| request.WordCount < 0)
{
return Task.FromResult<WordCompanionManuscriptAnalyseResponse?>(null);
}
return repository.AnalyseManuscriptAsync(RequireUserId(), request);
}
public Task<WordCompanionManuscriptImportResponse?> 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<WordCompanionManuscriptImportResponse?>(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<WordCompanionDiscoverCharactersResponse?> 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<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request)
{
if (request.ProjectId <= 0 || request.CandidateNames.Count == 0)
{
return Task.FromResult<WordCompanionCreateDiscoveredCharactersResponse?>(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<WordCompanionCreateDiscoveredCharactersResponse?>(null)
: repository.CreateDiscoveredCharactersAsync(RequireUserId(), request);
}
private async Task<WordCompanionResolveSceneResponse?> 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<int> ids) => ids.Any(id => id <= 0);
private static IReadOnlyList<WordCompanionCharacterCandidateDto> DiscoverCharacterCandidates(string manuscriptText, bool includeExcluded = false)
{
if (string.IsNullOrWhiteSpace(manuscriptText))
{
return [];
}
manuscriptText = NormalizeApostrophes(manuscriptText);
var candidates = new Dictionary<string, CharacterCandidateEvidence>(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+(?<verb>[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*(?<tag>[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(?<tag>[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
};
}