From ac300a94c8bd704af63a92f3c69f8dee165889b8 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 27 Jun 2026 20:08:19 +0100 Subject: [PATCH] Phase 16D - Major Character Discovery During Manuscript Import --- .../Controllers/WordCompanionController.cs | 14 ++ PlotLine/Data/WordCompanionRepository.cs | 45 ++++++ PlotLine/Models/WordCompanionApiModels.cs | 40 +++++ PlotLine/Services/WordCompanionService.cs | 115 ++++++++++++++ ...ase16D_WordCompanionCharacterDiscovery.sql | 132 +++++++++++++++++ PlotLine/Views/WordCompanionHost/Index.cshtml | 11 ++ PlotLine/wwwroot/css/word-companion.css | 44 ++++++ PlotLine/wwwroot/css/word-companion.min.css | 2 +- PlotLine/wwwroot/js/word-companion-host.js | 140 +++++++++++++++++- .../wwwroot/js/word-companion-host.min.js | 8 +- 10 files changed, 543 insertions(+), 8 deletions(-) create mode 100644 PlotLine/Sql/096_Phase16D_WordCompanionCharacterDiscovery.sql diff --git a/PlotLine/Controllers/WordCompanionController.cs b/PlotLine/Controllers/WordCompanionController.cs index ee5683a..95d41db 100644 --- a/PlotLine/Controllers/WordCompanionController.cs +++ b/PlotLine/Controllers/WordCompanionController.cs @@ -129,4 +129,18 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion) var response = await wordCompanion.ImportManuscriptAsync(request); return response is null ? BadRequest() : Ok(response); } + + [HttpPost("manuscript/discover-characters")] + public async Task DiscoverCharacters(WordCompanionDiscoverCharactersRequest request) + { + var response = await wordCompanion.DiscoverCharacterCandidatesAsync(request); + return response is null ? BadRequest() : Ok(response); + } + + [HttpPost("manuscript/create-discovered-characters")] + public async Task CreateDiscoveredCharacters(WordCompanionCreateDiscoveredCharactersRequest request) + { + var response = await wordCompanion.CreateDiscoveredCharactersAsync(request); + return response is null ? BadRequest() : Ok(response); + } } diff --git a/PlotLine/Data/WordCompanionRepository.cs b/PlotLine/Data/WordCompanionRepository.cs index 9355c19..585d08c 100644 --- a/PlotLine/Data/WordCompanionRepository.cs +++ b/PlotLine/Data/WordCompanionRepository.cs @@ -21,6 +21,8 @@ public interface IWordCompanionRepository Task UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn); Task AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request); Task ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request); + Task HasProjectAccessAsync(int projectId, int userId); + Task CreateDiscoveredCharactersAsync(int userId, WordCompanionCreateDiscoveredCharactersRequest request); } public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFactory) : IWordCompanionRepository @@ -303,6 +305,49 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact }; } + public async Task HasProjectAccessAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.WordCompanion_Project_HasAccess", + new { ProjectID = projectId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreateDiscoveredCharactersAsync(int userId, WordCompanionCreateDiscoveredCharactersRequest request) + { + var names = request.CandidateNames + .Select(name => new { Name = name }) + .ToList(); + + using var connection = connectionFactory.CreateConnection(); + using var result = await connection.QueryMultipleAsync( + "dbo.WordCompanion_Character_CreateDiscovered", + new + { + UserID = userId, + request.ProjectId, + CandidatesJson = System.Text.Json.JsonSerializer.Serialize(names) + }, + commandType: CommandType.StoredProcedure); + + var header = await result.ReadSingleOrDefaultAsync(); + if (header is null) + { + return null; + } + + var characters = (await result.ReadAsync()).ToList(); + return new WordCompanionCreateDiscoveredCharactersResponse + { + ProjectId = header.ProjectId, + CreatedCount = header.CreatedCount, + SkippedCount = header.SkippedCount, + Message = header.Message, + Characters = characters + }; + } + private static string ToCsv(IEnumerable ids) => string.Join(',', ids.Where(id => id > 0).Distinct()); private sealed class WordCompanionSceneStructureRow diff --git a/PlotLine/Models/WordCompanionApiModels.cs b/PlotLine/Models/WordCompanionApiModels.cs index e8f9f5f..610cd1c 100644 --- a/PlotLine/Models/WordCompanionApiModels.cs +++ b/PlotLine/Models/WordCompanionApiModels.cs @@ -333,3 +333,43 @@ public sealed class WordCompanionManuscriptImportResponse public int ArchivedScenes { get; init; } public WordCompanionManuscriptDocumentDto? ManuscriptDocument { get; init; } } + +public sealed class WordCompanionCharacterCandidateDto +{ + public string Text { get; init; } = string.Empty; + public int MentionCount { get; init; } +} + +public sealed class WordCompanionDiscoverCharactersRequest +{ + public int ProjectId { get; set; } + public string? DocumentText { get; set; } +} + +public sealed class WordCompanionDiscoverCharactersResponse +{ + public int ProjectId { get; init; } + public IReadOnlyList Candidates { get; init; } = []; +} + +public sealed class WordCompanionCreateDiscoveredCharactersRequest +{ + public int ProjectId { get; set; } + public IReadOnlyList CandidateNames { get; set; } = []; +} + +public sealed class WordCompanionCreatedCharacterDto +{ + public int CharacterId { get; init; } + public string Name { get; init; } = string.Empty; + public bool Created { get; init; } +} + +public sealed class WordCompanionCreateDiscoveredCharactersResponse +{ + public int ProjectId { get; init; } + public int CreatedCount { get; init; } + public int SkippedCount { get; init; } + public IReadOnlyList Characters { get; init; } = []; + public string Message { get; init; } = "Characters created."; +} diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index 519a083..a3ca6d3 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; @@ -22,6 +23,8 @@ public interface IWordCompanionService Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request); Task AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request); Task ImportManuscriptAsync(WordCompanionManuscriptImportRequest request); + Task DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request); + Task CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request); } public sealed class WordCompanionService( @@ -29,6 +32,23 @@ public sealed class WordCompanionService( IManuscriptDocumentRepository manuscriptDocuments, ICurrentUserService currentUser) : IWordCompanionService { + private const int CharacterDiscoveryMinimumMentions = 5; + private const int CharacterDiscoverySuggestionLimit = 50; + private static readonly Regex CharacterCandidateRegex = new( + @"\b(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?){0,2}\b", + RegexOptions.Compiled); + private static readonly HashSet 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", + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", + "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", + "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" + }; public async Task ListProjectsAsync() { var userId = RequireUserId(); @@ -264,6 +284,46 @@ public sealed class WordCompanionService( 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); + 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()); @@ -276,6 +336,61 @@ public sealed class WordCompanionService( private static bool HasInvalidIds(IEnumerable ids) => ids.Any(id => id <= 0); + private static IReadOnlyList DiscoverCharacterCandidates(string manuscriptText) + { + if (string.IsNullOrWhiteSpace(manuscriptText)) + { + return []; + } + + var mentions = new Dictionary(StringComparer.Ordinal); + foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText)) + { + var candidate = NormalizeCandidateName(match.Value); + if (!IsLikelyCharacterCandidate(candidate)) + { + continue; + } + + mentions[candidate] = mentions.TryGetValue(candidate, out var count) ? count + 1 : 1; + } + + return mentions + .Where(item => item.Value >= CharacterDiscoveryMinimumMentions) + .OrderByDescending(item => item.Value) + .ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase) + .Take(CharacterDiscoverySuggestionLimit) + .Select(item => new WordCompanionCharacterCandidateDto { Text = item.Key, MentionCount = item.Value }) + .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 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 static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new() { ManuscriptDocumentId = document.ManuscriptDocumentID, diff --git a/PlotLine/Sql/096_Phase16D_WordCompanionCharacterDiscovery.sql b/PlotLine/Sql/096_Phase16D_WordCompanionCharacterDiscovery.sql new file mode 100644 index 0000000..0bbb5b6 --- /dev/null +++ b/PlotLine/Sql/096_Phase16D_WordCompanionCharacterDiscovery.sql @@ -0,0 +1,132 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Project_HasAccess + @ProjectID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT CAST(CASE WHEN EXISTS + ( + SELECT 1 + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE p.ProjectID = @ProjectID + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + THEN 1 ELSE 0 END AS bit) AS HasAccess; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Character_CreateDiscovered + @UserID int, + @ProjectID int, + @CandidatesJson nvarchar(max) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE p.ProjectID = @ProjectID + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + RETURN; + + DECLARE @Candidates table + ( + Name nvarchar(200) NOT NULL PRIMARY KEY + ); + + INSERT @Candidates (Name) + SELECT DISTINCT LEFT(LTRIM(RTRIM(JSON_VALUE(value, '$.Name'))), 200) + FROM OPENJSON(@CandidatesJson) + WHERE NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.Name'))), N'') IS NOT NULL; + + IF NOT EXISTS (SELECT 1 FROM @Candidates) + RETURN; + + DECLARE @Results table + ( + CharacterId int NOT NULL, + Name nvarchar(200) NOT NULL, + Created bit NOT NULL + ); + + INSERT @Results (CharacterId, Name, Created) + SELECT ch.CharacterID, + c.Name, + CAST(0 AS bit) + FROM @Candidates c + INNER JOIN dbo.Characters ch ON ch.ProjectID = @ProjectID + AND ch.IsArchived = 0 + AND UPPER(LTRIM(RTRIM(ch.CharacterName))) = UPPER(c.Name); + + DECLARE @Missing table + ( + Name nvarchar(200) NOT NULL PRIMARY KEY + ); + + INSERT @Missing (Name) + SELECT c.Name + FROM @Candidates c + WHERE NOT EXISTS + ( + SELECT 1 + FROM dbo.Characters ch + WHERE ch.ProjectID = @ProjectID + AND ch.IsArchived = 0 + AND UPPER(LTRIM(RTRIM(ch.CharacterName))) = UPPER(c.Name) + ); + + DECLARE @Inserted table + ( + CharacterID int NOT NULL, + CharacterName nvarchar(200) NOT NULL + ); + + INSERT dbo.Characters (ProjectID, CharacterName, ShortName, CharacterImportance, ShowInQuickAddBar, DefaultDescription) + OUTPUT inserted.CharacterID, inserted.CharacterName INTO @Inserted (CharacterID, CharacterName) + SELECT @ProjectID, + m.Name, + NULL, + 8, + 1, + N'Created from Word Companion manuscript import.' + FROM @Missing m + ORDER BY m.Name; + + INSERT @Results (CharacterId, Name, Created) + SELECT CharacterID, + CharacterName, + CAST(1 AS bit) + FROM @Inserted; + + SELECT @ProjectID AS ProjectId, + SUM(CASE WHEN Created = 1 THEN 1 ELSE 0 END) AS CreatedCount, + SUM(CASE WHEN Created = 0 THEN 1 ELSE 0 END) AS SkippedCount, + CASE + WHEN SUM(CASE WHEN Created = 1 THEN 1 ELSE 0 END) = 0 THEN N'No new characters were created.' + ELSE N'Characters created.' + END AS Message + FROM @Results; + + SELECT CharacterId, + Name, + Created + FROM @Results + ORDER BY Created DESC, Name; +END; +GO diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 15d0232..997c952 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -71,6 +71,17 @@

Select a Project and Book to begin.

+