Use AI for scan character sex hints

This commit is contained in:
Nick Beckley 2026-08-16 20:51:12 +00:00
parent 06c4bf6023
commit c96c20d18b
5 changed files with 267 additions and 23 deletions

View File

@ -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.");
}

View File

@ -199,6 +199,7 @@ public class Program
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
builder.Services.AddScoped<IOnboardingStoryIntelligenceService, OnboardingStoryIntelligenceService>();
builder.Services.AddScoped<ICharacterSexHintAiService, CharacterSexHintAiService>();
builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>();
builder.Services.AddSingleton<IStoryPromptRepository, StoryPromptRepository>();

View File

@ -0,0 +1,152 @@
using System.Text.Json;
using PlotLine.Models;
namespace PlotLine.Services;
public interface ICharacterSexHintAiService
{
Task<IReadOnlyDictionary<string, string>> SuggestAsync(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates,
CancellationToken cancellationToken);
}
public sealed class CharacterSexHintAiService(
IStoryIntelligenceClient client,
ILogger<CharacterSexHintAiService> logger) : ICharacterSexHintAiService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyDictionary<string, string>> SuggestAsync(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> 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<string, string>();
}
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<string, string>();
}
}
private static string BuildPrompt(IReadOnlyList<CharacterSexHintPromptCandidate> 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<OpenAIResponseEnvelope>(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<string, string> ParseSuggestions(
string outputText,
IReadOnlyList<CharacterSexHintPromptCandidate> candidates)
{
if (string.IsNullOrWhiteSpace(outputText))
{
return new Dictionary<string, string>();
}
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<string, string>();
}
var suggestions = new Dictionary<string, string>(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);
}

View File

@ -9,21 +9,30 @@ public static class CharacterSexHintService
private static readonly HashSet<string> 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<string> 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<string> AmbiguousNames = new(StringComparer.OrdinalIgnoreCase)
@ -33,9 +42,26 @@ public static class CharacterSexHintService
"sam", "sandy", "shannon", "taylor", "terry"
};
private static readonly Dictionary<string, string> 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();
}
}

View File

@ -21,7 +21,9 @@ public interface IManuscriptScanPreviewStore
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
}
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
public sealed class ManuscriptScanPreviewStore(
IServiceScopeFactory scopeFactory,
ILogger<ManuscriptScanPreviewStore> logger) : IManuscriptScanPreviewStore
{
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
@ -148,15 +150,21 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
public Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
public async Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
return null;
}
var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview));
return Task.FromResult<ManuscriptScanReviewDecision?>(review);
if (reviews.TryGetValue(previewId, out var review))
{
return review;
}
review = await BuildDefaultReviewAsync(preview);
var stored = reviews.GetOrAdd(previewId, review);
return stored;
}
public Task<ManuscriptScanReviewDecision?> 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<ManuscriptScanReviewDecision> 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<IReadOnlyDictionary<string, string>> GetAiSexHintsAsync(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
{
if (candidates.Count == 0)
{
return new Dictionary<string, string>();
}
try
{
using var scope = scopeFactory.CreateScope();
var hintService = scope.ServiceProvider.GetRequiredService<ICharacterSexHintAiService>();
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<string, string>();
}
}
private sealed class ScanSession
{