PlotDirector/PlotLine/Services/CharacterSexHintAiService.cs

153 lines
5.7 KiB
C#

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);
}