PlotDirector/PlotLine/Services/WordCompanionService.cs
2026-06-27 20:16:42 +01:00

606 lines
24 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<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest 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 = 5;
private const int CharacterDiscoverySuggestionLimit = 50;
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}(?!['\p{L}])",
RegexOptions.Compiled);
private static readonly HashSet<string> CharacterDiscoveryStopWords = new(StringComparer.OrdinalIgnoreCase)
{
"A", "An", "And", "As", "At", "But", "By", "For", "From", "He", "Her", "His", "I", "If", "In", "Into",
"It", "Its", "Of", "On", "Or", "Our", "She", "So", "That", "The", "Their", "Then", "There", "They",
"This", "To", "We", "When", "Where", "Who", "With", "You", "Your",
"Christmas", "Easter", "Morning", "Afternoon", "Evening", "Night", "Today", "Tomorrow", "Yesterday",
"Chapter", "Scene", "Part", "Prologue", "Epilogue", "Book", "Volume", "Act",
"Mum", "Dad", "Mother", "Father", "Grandma", "Grandad", "Nan", "Nana", "Granny",
"Yes", "No", "Okay", "Ok", "Hello", "Goodbye",
"What", "Not", "Just", "Yeah", "How", "Well", "Are", "Like", "One", "Can", "Thank", "Why", "Come",
"Let", "Maybe", "Please", "God", "Something", "After", "Did", "Even", "All", "Nothing", "Have",
"Don", "Won", "Could", "Would", "Should", "Do", "Does", "Had", "Has", "Was", "Were", "Is", "Am",
"Right", "Really", "Now", "Look", "Listen", "Sorry", "Thanks", "Fine", "Great", "Sure", "Course",
"Thing", "Things", "Everyone", "Everything", "Anything", "Someone", "Nobody", "People"
};
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"
};
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 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 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);
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)
{
if (string.IsNullOrWhiteSpace(manuscriptText))
{
return [];
}
var candidates = new Dictionary<string, CharacterCandidateEvidence>(StringComparer.Ordinal);
foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText))
{
var candidate = NormalizeCandidateName(match.Value);
if (!IsLikelyCharacterCandidate(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;
}
}
return candidates.Values
.Where(item => item.MentionCount >= CharacterDiscoveryMinimumMentions)
.Select(BuildCandidateDto)
.Where(item => item is not null)
.Select(item => item!)
.OrderByDescending(item => ConfidenceSortValue(item.Confidence))
.ThenByDescending(item => item.MentionCount)
.ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
.Take(CharacterDiscoverySuggestionLimit)
.ToList();
}
private static bool IsLikelyCharacterCandidate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate) || candidate.Length > 200)
{
return false;
}
var words = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length == 0 || words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word))))
{
return false;
}
var first = TrimHonorificPunctuation(words[0]);
return words.Length > 1 || first.Length > 2;
}
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
{
var score = 0;
var words = evidence.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var first = words.Length > 0 ? TrimHonorificPunctuation(words[0]) : string.Empty;
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 nonSentenceStartMentions = evidence.MentionCount - evidence.SentenceStartMentions;
var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m);
if (evidence.MentionCount >= CharacterDiscoveryMinimumMentions)
{
score += 1;
}
if (evidence.MentionCount >= 10)
{
score += 1;
}
if (nonSentenceStartMentions > 0)
{
score += 2;
}
if (evidence.DialogueEvidenceCount > 0)
{
score += 3;
}
if (hasMultiWordName)
{
score += 2;
}
if (hasHonorific)
{
score += 2;
}
if (hasNameLikeWord)
{
score += 3;
}
if (hasTemporalWord)
{
score -= 1;
}
if (mostlySentenceStart)
{
score -= 2;
}
if (!hasMultiWordName && evidence.DialogueEvidenceCount == 0 && nonSentenceStartMentions == 0)
{
score -= 1;
}
if (score < 0)
{
return null;
}
var confidence = score >= 5 ? "High" : score >= 2 ? "Medium" : "Low";
return new WordCompanionCharacterCandidateDto
{
Text = evidence.Text,
MentionCount = evidence.MentionCount,
Confidence = confidence,
Reason = BuildCandidateReason(evidence, hasHonorific, hasMultiWordName, hasTemporalWord, mostlySentenceStart)
};
}
private static string BuildCandidateReason(
CharacterCandidateEvidence evidence,
bool hasHonorific,
bool hasMultiWordName,
bool hasTemporalWord,
bool mostlySentenceStart)
{
if (evidence.DialogueEvidenceCount > 0)
{
return "Dialogue evidence";
}
if (hasHonorific)
{
return "Title or honorific";
}
if (hasMultiWordName)
{
return "Proper-name phrase";
}
if (hasTemporalWord)
{
return "Possible name";
}
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 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 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(candidate ?? string.Empty, @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\'', '');
}
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; }
}
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
};
}