153 lines
5.7 KiB
C#
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);
|
|
}
|