606 lines
24 KiB
C#
606 lines
24 KiB
C#
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
|
||
};
|
||
}
|