From c96c20d18b989c7117c42b94aa5e30fae925f361 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 16 Aug 2026 20:51:12 +0000 Subject: [PATCH] Use AI for scan character sex hints --- PlotLine.Tests/Program.cs | 11 ++ PlotLine/Program.cs | 1 + .../Services/CharacterSexHintAiService.cs | 152 ++++++++++++++++++ PlotLine/Services/CharacterSexHintService.cs | 76 +++++++-- .../Services/ManuscriptScanPreviewStore.cs | 50 +++++- 5 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 PlotLine/Services/CharacterSexHintAiService.cs diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index a950b15..04cc92d 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -846,7 +846,18 @@ static void CharacterSexHintsAreConservativeAndReviewable() { Assert(CharacterSexHintService.Suggest("Cindy") == CharacterSexHintService.Female, "Cindy should default to a female sex hint."); Assert(CharacterSexHintService.Suggest("Kevin") == CharacterSexHintService.Male, "Kevin should default to a male sex hint."); + Assert(CharacterSexHintService.Suggest("Colin") == CharacterSexHintService.Male, "Colin should default to a male sex hint."); + Assert(CharacterSexHintService.Suggest("Debbie") == CharacterSexHintService.Female, "Debbie should default to a female sex hint."); + Assert(CharacterSexHintService.Suggest("Chrissy") == CharacterSexHintService.Female, "Chrissy should default to a female sex hint."); + Assert(CharacterSexHintService.Suggest("Clara") == CharacterSexHintService.Female, "Clara should default to a female sex hint."); + Assert(CharacterSexHintService.Suggest("Greg") == CharacterSexHintService.Male, "Greg should default to a male sex hint."); + Assert(CharacterSexHintService.Suggest("Judy") == CharacterSexHintService.Female, "Judy should default to a female sex hint."); + Assert(CharacterSexHintService.Suggest("Liam") == CharacterSexHintService.Male, "Liam should default to a male sex hint."); + Assert(CharacterSexHintService.Suggest("Phil") == CharacterSexHintService.Male, "Phil should default to a male sex hint."); + Assert(CharacterSexHintService.Suggest("Mrs Palmer") == CharacterSexHintService.Female, "Feminine titles should provide a female sex hint."); + Assert(CharacterSexHintService.Suggest("Mr Palmer") == CharacterSexHintService.Male, "Masculine titles should provide a male sex hint."); Assert(CharacterSexHintService.Suggest("Alex") == CharacterSexHintService.Unknown, "Ambiguous names should not be guessed."); + Assert(CharacterSexHintService.Suggest("Becks") == CharacterSexHintService.Unknown, "Unclear nicknames should remain reviewable."); Assert(CharacterSexHintService.Normalise("Female", "Alex") == CharacterSexHintService.Female, "Reviewed sex hints should override the name heuristic."); } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index ff0271a..32bdee2 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -199,6 +199,7 @@ public class Program builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/PlotLine/Services/CharacterSexHintAiService.cs b/PlotLine/Services/CharacterSexHintAiService.cs new file mode 100644 index 0000000..0bb83a9 --- /dev/null +++ b/PlotLine/Services/CharacterSexHintAiService.cs @@ -0,0 +1,152 @@ +using System.Text.Json; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface ICharacterSexHintAiService +{ + Task> SuggestAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken); +} + +public sealed class CharacterSexHintAiService( + IStoryIntelligenceClient client, + ILogger logger) : ICharacterSexHintAiService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task> SuggestAsync( + IReadOnlyList candidates, + CancellationToken cancellationToken) + { + var promptCandidates = candidates + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.TemporaryCharacterKey) && !string.IsNullOrWhiteSpace(candidate.Name)) + .Select(candidate => new CharacterSexHintPromptCandidate(candidate.TemporaryCharacterKey, candidate.Name.Trim())) + .DistinctBy(candidate => candidate.Key, StringComparer.Ordinal) + .ToList(); + if (promptCandidates.Count == 0) + { + return new Dictionary(); + } + + try + { + var result = await client.ExecutePromptAsync( + BuildPrompt(promptCandidates), + "Character-Sex-Hint-V1", + cancellationToken, + maxOutputTokensOverride: Math.Max(600, promptCandidates.Count * 20)); + var outputText = ExtractOutputText(result.RawResponseText); + return ParseSuggestions(outputText, promptCandidates); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "AI character sex hinting failed; falling back to deterministic title/name hints."); + return new Dictionary(); + } + } + + private static string BuildPrompt(IReadOnlyList candidates) + { + var payload = JsonSerializer.Serialize(candidates.Select(candidate => new + { + key = candidate.Key, + name = candidate.Name + }), JsonOptions); + + return $$""" + You are helping prepare a fiction manuscript import review. + + For each candidate character name, infer the most probable sex presentation from the name/title alone. + Return only valid JSON in this exact shape: + { + "characters": [ + { "key": "same key", "sexHint": "Female|Male|NonBinaryOrOther|Unknown" } + ] + } + + Rules: + - Use Female or Male for common names and explicit titles such as Miss, Mrs, Mr, Sir, Lady. + - Use Unknown when the name is genuinely ambiguous, invented, a surname-only reference, or not enough information is present. + - Use NonBinaryOrOther only when the supplied name/title explicitly indicates that. + - Do not add explanations. + + Candidates: + {{payload}} + """; + } + + private static string ExtractOutputText(string rawResponseText) + { + var response = JsonSerializer.Deserialize(rawResponseText, JsonOptions) + ?? throw new JsonException("OpenAI response envelope was empty."); + return string.Concat(response.Output? + .SelectMany(item => item.Content ?? []) + .Where(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)) + .Select(content => content.Text) + .Where(text => !string.IsNullOrWhiteSpace(text)) + ?? []); + } + + private static IReadOnlyDictionary ParseSuggestions( + string outputText, + IReadOnlyList candidates) + { + if (string.IsNullOrWhiteSpace(outputText)) + { + return new Dictionary(); + } + + var candidateKeys = candidates.Select(candidate => candidate.Key).ToHashSet(StringComparer.Ordinal); + using var document = JsonDocument.Parse(StripMarkdownFence(outputText)); + if (!document.RootElement.TryGetProperty("characters", out var characters) + || characters.ValueKind != JsonValueKind.Array) + { + return new Dictionary(); + } + + var suggestions = new Dictionary(StringComparer.Ordinal); + foreach (var item in characters.EnumerateArray()) + { + if (!item.TryGetProperty("key", out var keyProperty) + || !item.TryGetProperty("sexHint", out var sexHintProperty)) + { + continue; + } + + var key = keyProperty.GetString(); + if (string.IsNullOrWhiteSpace(key) || !candidateKeys.Contains(key)) + { + continue; + } + + var hint = CharacterSexHintService.Normalise(sexHintProperty.GetString()); + suggestions[key] = hint; + } + + return suggestions; + } + + private static string StripMarkdownFence(string value) + { + var trimmed = value.Trim(); + if (!trimmed.StartsWith("```", StringComparison.Ordinal)) + { + return trimmed; + } + + var firstLineEnd = trimmed.IndexOf('\n'); + if (firstLineEnd < 0) + { + return trimmed; + } + + var withoutOpeningFence = trimmed[(firstLineEnd + 1)..].Trim(); + return withoutOpeningFence.EndsWith("```", StringComparison.Ordinal) + ? withoutOpeningFence[..^3].Trim() + : withoutOpeningFence; + } + + private sealed record CharacterSexHintPromptCandidate(string Key, string Name); +} diff --git a/PlotLine/Services/CharacterSexHintService.cs b/PlotLine/Services/CharacterSexHintService.cs index 9c599ca..3f3b3b1 100644 --- a/PlotLine/Services/CharacterSexHintService.cs +++ b/PlotLine/Services/CharacterSexHintService.cs @@ -9,21 +9,30 @@ public static class CharacterSexHintService private static readonly HashSet FemaleNames = new(StringComparer.OrdinalIgnoreCase) { - "abby", "abigail", "alice", "alison", "allison", "amanda", "amelia", "amy", "angela", "anna", "anne", - "beth", "bethany", "carol", "caroline", "catherine", "charlotte", "chloe", "cindy", "claire", "daisy", - "diana", "emily", "emma", "eva", "eve", "grace", "hannah", "helen", "isabel", "isabella", "jane", - "jessica", "julia", "julie", "kate", "katie", "laura", "lily", "lucy", "maggie", "margaret", "mary", - "molly", "nicola", "olivia", "rachel", "rebecca", "rose", "sarah", "sophie", "susan", "victoria", - "zoe" + "abby", "abigail", "ada", "adele", "alice", "alison", "allison", "amanda", "amelia", "amy", + "angela", "anna", "anne", "annie", "barbara", "becky", "belinda", "beth", "bethany", "brenda", + "carol", "caroline", "catherine", "charlotte", "chloe", "chrissy", "christine", "cindy", "claire", "clara", + "daisy", "debbie", "debby", "deborah", "diana", "diane", "donna", "dorothy", "elaine", "elizabeth", + "ella", "ellie", "emily", "emma", "eva", "eve", "fiona", "gemma", "georgina", "grace", "hannah", + "harriet", "heather", "helen", "isabel", "isabella", "jacqueline", "jane", "janet", "janice", + "jean", "jennifer", "jenny", "jessica", "joanne", "judy", "julia", "julie", "karen", "kate", "katie", + "katherine", "kathryn", "kelly", "laura", "lesley", "linda", "lisa", "lily", "louise", "lucy", + "maggie", "margaret", "maria", "marie", "marion", "mary", "michelle", "molly", "nicola", "olivia", + "pamela", "patricia", "paula", "rachel", "rebecca", "rose", "sally", "samantha", "sandra", "sarah", + "shirley", "sophie", "stephanie", "susan", "suzanne", "tracey", "tracy", "valerie", "victoria", + "wendy", "zoe" }; private static readonly HashSet MaleNames = new(StringComparer.OrdinalIgnoreCase) { - "adam", "alan", "alexander", "andrew", "anthony", "arthur", "ben", "benjamin", "brian", "charles", - "chris", "christopher", "daniel", "david", "edward", "george", "harry", "henry", "ian", "jack", - "james", "john", "jonathan", "joseph", "kevin", "mark", "matthew", "michael", "nick", "nicolas", - "nicholas", "oliver", "paul", "peter", "philip", "richard", "robert", "samuel", "simon", "stephen", - "steven", "thomas", "tom", "william" + "adam", "alan", "albert", "alexander", "andrew", "anthony", "arthur", "barry", "ben", "benjamin", + "bernard", "brian", "bruce", "charles", "chris", "christopher", "colin", "craig", "daniel", "darren", + "david", "dennis", "douglas", "edward", "eric", "frank", "gary", "geoff", "geoffrey", "george", + "graham", "grant", "greg", "harry", "henry", "howard", "ian", "jack", "james", "john", "jonathan", "joseph", + "keith", "kenneth", "kevin", "liam", "martin", "mark", "matthew", "michael", "neil", "nick", "nicolas", + "nicholas", "nigel", "oliver", "paul", "peter", "phil", "philip", "raymond", "richard", "robert", "roger", + "ronald", "samuel", "simon", "stephen", "steven", "stuart", "terence", "thomas", "tim", "timothy", + "tom", "tony", "william" }; private static readonly HashSet AmbiguousNames = new(StringComparer.OrdinalIgnoreCase) @@ -33,9 +42,26 @@ public static class CharacterSexHintService "sam", "sandy", "shannon", "taylor", "terry" }; + private static readonly Dictionary TitleHints = new(StringComparer.OrdinalIgnoreCase) + { + ["mr"] = Male, + ["mister"] = Male, + ["sir"] = Male, + ["mrs"] = Female, + ["miss"] = Female, + ["ms"] = Female, + ["madam"] = Female, + ["lady"] = Female + }; + public static string Suggest(string? name) { - var token = FirstNameToken(name); + var token = FirstNameToken(name, out var titleHint); + if (!string.IsNullOrWhiteSpace(titleHint)) + { + return titleHint; + } + if (string.IsNullOrWhiteSpace(token) || AmbiguousNames.Contains(token)) { return Unknown; @@ -59,15 +85,35 @@ public static class CharacterSexHintService _ => Suggest(fallbackName) }; - private static string FirstNameToken(string? name) + private static string FirstNameToken(string? name, out string? titleHint) { + titleHint = null; var clean = name?.Trim(); if (string.IsNullOrWhiteSpace(clean)) { return string.Empty; } - var first = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; - return first.Trim('.', ',', ';', ':', '\'', '"').ToLowerInvariant(); + var tokens = clean + .Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries) + .Select(CleanToken) + .Where(token => !string.IsNullOrWhiteSpace(token)) + .ToList(); + if (tokens.Count == 0) + { + return string.Empty; + } + + if (TitleHints.TryGetValue(tokens[0], out titleHint)) + { + return tokens.Skip(1).FirstOrDefault() ?? string.Empty; + } + + return tokens[0]; + } + + private static string CleanToken(string token) + { + return token.Trim('.', ',', ';', ':', '\'', '"', '(', ')', '[', ']').ToLowerInvariant(); } } diff --git a/PlotLine/Services/ManuscriptScanPreviewStore.cs b/PlotLine/Services/ManuscriptScanPreviewStore.cs index 83365d9..623ea03 100644 --- a/PlotLine/Services/ManuscriptScanPreviewStore.cs +++ b/PlotLine/Services/ManuscriptScanPreviewStore.cs @@ -21,7 +21,9 @@ public interface IManuscriptScanPreviewStore Task SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result); } -public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore +public sealed class ManuscriptScanPreviewStore( + IServiceScopeFactory scopeFactory, + ILogger logger) : IManuscriptScanPreviewStore { private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new(); private readonly ConcurrentDictionary previews = new(); @@ -148,15 +150,21 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore public Task GetPreviewAsync(int userId, Guid previewId) => Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null); - public Task GetReviewAsync(int userId, Guid previewId) + public async Task GetReviewAsync(int userId, Guid previewId) { if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId) { - return Task.FromResult(null); + return null; } - var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview)); - return Task.FromResult(review); + if (reviews.TryGetValue(previewId, out var review)) + { + return review; + } + + review = await BuildDefaultReviewAsync(preview); + var stored = reviews.GetOrAdd(previewId, review); + return stored; } public Task SaveReviewAsync(int userId, ManuscriptScanReviewDecision review) @@ -246,8 +254,10 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned; } - private static ManuscriptScanReviewDecision BuildDefaultReview(ManuscriptScanPreview preview) - => new() + private async Task BuildDefaultReviewAsync(ManuscriptScanPreview preview) + { + var aiHints = await GetAiSexHintsAsync(preview.CharacterCandidates); + return new ManuscriptScanReviewDecision { PreviewID = preview.PreviewID, Status = ManuscriptScanReviewStatuses.ScanComplete, @@ -280,10 +290,34 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase), Name = candidate.Name, Category = candidate.Category, - SexHint = CharacterSexHintService.Suggest(candidate.Name) + SexHint = aiHints.TryGetValue(candidate.TemporaryCharacterKey, out var aiHint) + ? aiHint + : CharacterSexHintService.Suggest(candidate.Name) }) .ToList() }; + } + + private async Task> GetAiSexHintsAsync( + IReadOnlyList candidates) + { + if (candidates.Count == 0) + { + return new Dictionary(); + } + + try + { + using var scope = scopeFactory.CreateScope(); + var hintService = scope.ServiceProvider.GetRequiredService(); + return await hintService.SuggestAsync(candidates, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not create AI character sex hints for manuscript scan review; using deterministic fallback hints."); + return new Dictionary(); + } + } private sealed class ScanSession {