Use AI for scan character sex hints
This commit is contained in:
parent
06c4bf6023
commit
c96c20d18b
@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@ -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>();
|
||||
|
||||
152
PlotLine/Services/CharacterSexHintAiService.cs
Normal file
152
PlotLine/Services/CharacterSexHintAiService.cs
Normal 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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user