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