PlotDirector/PlotLine/Services/CharacterEnrichmentService.cs

722 lines
33 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface ICharacterEnrichmentService
{
Task<CharacterEnrichmentRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId);
Task<CharacterEnrichmentRun?> QueueForCharacterAsync(int characterId, int userId, int? bookId = null);
Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId);
Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId);
Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId);
Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId);
Task<bool> ProcessNextAsync(CancellationToken cancellationToken);
string BuildPromptForTest(CharacterEnrichmentContext context);
StoryIntelligenceResponseContract BuildResponseContractForTest();
}
public sealed class CharacterEnrichmentService(
ICharacterEnrichmentRepository repository,
IStoryIntelligencePipelineRepository pipelines,
IStoryIntelligenceClient client,
IOptions<StoryIntelligenceOptions> options,
IStoryIntelligenceProgressNotifier notifier,
ILogger<CharacterEnrichmentService> logger) : ICharacterEnrichmentService
{
private const string PromptVersion = "Character-Enrichment-V1";
private const int BatchSize = 8;
private const int MaxPromptCharacters = 350_000;
private const int MainCharacterPromptCharacters = 175_000;
private const int MinimumSingleCharacterScenes = 8;
private const decimal SummaryMinimumConfidence = 0.65m;
private const decimal FactMinimumConfidence = 0.9m;
private static readonly Regex LeadingAgeRegex = new(@"\b(?<age>\d{1,3})\b", RegexOptions.Compiled);
private static readonly Regex BirthdayAgeDateRegex = new(@"\b(?<age>\d{1,3})(?:st|nd|rd|th)?\s+(?:birthday|on)\s+(?:on\s+)?(?<date>\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]+\s+\d{4})", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly string[] DateFormats =
[
"d MMMM yyyy",
"dd MMMM yyyy",
"d MMM yyyy",
"dd MMM yyyy",
"yyyy-MM-dd"
];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly StoryIntelligenceOptions settings = options.Value;
public async Task<CharacterEnrichmentRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId)
{
var pipeline = await pipelines.GetByBookForUserAsync(bookId, userId);
return await repository.QueueAsync(new CharacterEnrichmentQueueRequest
{
ProjectID = projectId,
BookID = bookId,
UserID = userId,
StoryIntelligenceBookPipelineID = pipeline?.StoryIntelligenceBookPipelineID,
CharacterResolutionVersion = $"CharacterImport:{bookId}"
});
}
public async Task<CharacterEnrichmentRun?> QueueForCharacterAsync(int characterId, int userId, int? bookId = null)
{
var run = await repository.QueueCharacterAsync(new CharacterEnrichmentCharacterQueueRequest
{
CharacterID = characterId,
UserID = userId,
BookID = bookId
});
if (run.WasCoalesced)
{
logger.LogInformation(
"Manual character enrichment request ignored because an active run already exists. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Status={Status}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
run.Status);
}
else
{
logger.LogInformation(
"Manual character enrichment queued. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Status={Status}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
run.Status);
}
return run;
}
public Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId)
=> repository.RetryAsync(bookId, userId);
public Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId)
=> repository.GetCurrentAsync(bookId);
public Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId)
=> repository.GetCurrentForCharacterAsync(characterId, userId);
public Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId)
=> repository.GetCharacterStatusAsync(characterId, userId);
public async Task<bool> ProcessNextAsync(CancellationToken cancellationToken)
{
var run = await repository.ClaimNextAsync(Math.Max(15, settings.ClaimLeaseMinutes));
if (run is null)
{
return false;
}
var stopwatch = Stopwatch.StartNew();
var results = new List<CharacterEnrichmentCharacterResult>();
var inputTokens = 0;
var outputTokens = 0;
var model = settings.EffectiveWholeBookPlotSynthesisModel;
try
{
logger.LogInformation(
"Character enrichment starting. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID);
await PublishProgressAsync(run, 0, 0, "Preparing character analysis", "Preparing character analysis...", cancellationToken);
var context = await repository.BuildContextAsync(run.ProjectID, run.BookID, run.CharacterID);
var characters = context.Characters
.Where(character => character.Scenes.Count > 0)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
logger.LogInformation(
"Character enrichment context prepared. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Characters={CharacterCount}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
characters.Count);
await PublishProgressAsync(run, characters.Count, 0, "Analysing characters", "Analysing characters...", cancellationToken);
foreach (var batchContext in BuildPromptBatches(context, characters, run))
{
cancellationToken.ThrowIfCancellationRequested();
var prompt = BuildPrompt(batchContext);
if (prompt.Length > MaxPromptCharacters)
{
throw new InvalidOperationException($"Character enrichment prompt would exceed the configured safe context limit. PromptCharacters={prompt.Length:N0} Limit={MaxPromptCharacters:N0}. No manuscript text was silently truncated.");
}
logger.LogInformation(
"Character enrichment batch starting. RunID={RunID} CharacterID={CharacterID} BatchCharacters={BatchCharacters} PromptCharacters={PromptCharacters} SceneCount={SceneCount} Processed={Processed}/{Total}",
run.CharacterEnrichmentRunID,
run.CharacterID,
batchContext.Characters.Count,
prompt.Length,
batchContext.Characters.Sum(character => character.Scenes.Count),
results.Count,
characters.Count);
await PublishProgressAsync(run, characters.Count, results.Count, "Creating summaries and extracting character details", "Creating summaries and extracting character details...", cancellationToken);
var clientResult = await client.ExecutePromptAsync(
prompt,
PromptVersion,
cancellationToken,
settings.EffectiveWholeBookPlotSynthesisModel,
settings.WholeBookPlotSynthesisMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens),
responseContract: CharacterEnrichmentStructuredOutputSchema.Contract);
model = clientResult.Model;
inputTokens += clientResult.InputTokens ?? 0;
outputTokens += clientResult.OutputTokens ?? 0;
var parsed = CharacterEnrichmentResult.FromJson(ExtractOutputText(clientResult.RawResponseText));
results.AddRange(FilterResults(parsed.Characters, batchContext.Characters.Select(character => character.CharacterID).ToHashSet()));
await PublishProgressAsync(run, characters.Count, Math.Min(characters.Count, results.Count), "Saving character analysis", "Saving character analysis...", cancellationToken);
}
stopwatch.Stop();
await repository.CompleteAsync(new CharacterEnrichmentCompletionRequest
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
Model = model,
InputTokens = inputTokens == 0 ? null : inputTokens,
OutputTokens = outputTokens == 0 ? null : outputTokens,
TotalTokens = inputTokens + outputTokens == 0 ? null : inputTokens + outputTokens,
DurationMs = stopwatch.ElapsedMilliseconds
}, results);
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Completed, results.Count, results.Count, "Complete", "Character summaries are ready."));
logger.LogInformation(
"Character enrichment completed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Characters={CharacterCount} DurationMs={DurationMs}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
results.Count,
stopwatch.ElapsedMilliseconds);
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
stopwatch.Stop();
await repository.FailAsync(run.CharacterEnrichmentRunID, ex.Message, ex.ToString(), stopwatch.ElapsedMilliseconds);
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Failed, run.TotalCharacters, run.ProcessedCharacters, "Failed", "Character enrichment failed.", ex.Message));
logger.LogError(ex, "Character enrichment failed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID}", run.CharacterEnrichmentRunID, run.ProjectID, run.BookID, run.CharacterID);
return true;
}
}
public string BuildPromptForTest(CharacterEnrichmentContext context)
=> BuildPrompt(context);
public StoryIntelligenceResponseContract BuildResponseContractForTest()
=> CharacterEnrichmentStructuredOutputSchema.Contract;
private IReadOnlyList<CharacterEnrichmentContext> BuildPromptBatches(
CharacterEnrichmentContext context,
IReadOnlyList<CharacterEnrichmentCharacterContext> characters,
CharacterEnrichmentRun run)
{
var batches = new List<CharacterEnrichmentContext>();
var current = new List<CharacterEnrichmentCharacterContext>();
foreach (var character in characters)
{
var single = BuildContextForCharacters(context, [character]);
var singlePromptLength = BuildPrompt(single).Length;
if (singlePromptLength > MaxPromptCharacters)
{
var restrained = RestrainSingleCharacterContext(context, character, run, singlePromptLength);
AddCurrentBatch();
batches.Add(restrained);
continue;
}
if (singlePromptLength >= MainCharacterPromptCharacters)
{
AddCurrentBatch();
batches.Add(single);
continue;
}
var candidate = BuildContextForCharacters(context, current.Concat([character]).ToList());
if (current.Count > 0 && (current.Count >= BatchSize || BuildPrompt(candidate).Length > MaxPromptCharacters))
{
AddCurrentBatch();
}
current.Add(character);
}
AddCurrentBatch();
return batches;
void AddCurrentBatch()
{
if (current.Count == 0)
{
return;
}
batches.Add(BuildContextForCharacters(context, current.ToList()));
current.Clear();
}
}
private CharacterEnrichmentContext RestrainSingleCharacterContext(
CharacterEnrichmentContext context,
CharacterEnrichmentCharacterContext character,
CharacterEnrichmentRun run,
int originalPromptLength)
{
var scenes = SelectRepresentativeScenes(character.Scenes, character.Scenes.Count).ToList();
while (scenes.Count > MinimumSingleCharacterScenes)
{
var candidate = BuildContextForCharacters(context, [CopyCharacter(character, scenes)]);
if (BuildPrompt(candidate).Length <= MaxPromptCharacters)
{
logger.LogWarning(
"Character enrichment constrained an evidence-heavy character to fit the safe context limit. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} CharacterName={CharacterName} OriginalPromptCharacters={OriginalPromptCharacters} FinalPromptCharacters={FinalPromptCharacters} OriginalScenes={OriginalScenes} IncludedScenes={IncludedScenes} Limit={Limit}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
character.CharacterID,
character.CharacterName,
originalPromptLength,
BuildPrompt(candidate).Length,
character.Scenes.Count,
scenes.Count,
MaxPromptCharacters);
return candidate;
}
scenes = SelectRepresentativeScenes(scenes, Math.Max(MinimumSingleCharacterScenes, scenes.Count - 4)).ToList();
}
var minimum = BuildContextForCharacters(context, [CopyCharacter(character, scenes)]);
var minimumPromptLength = BuildPrompt(minimum).Length;
if (minimumPromptLength <= MaxPromptCharacters)
{
logger.LogWarning(
"Character enrichment constrained an evidence-heavy character to the minimum scene set. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} CharacterName={CharacterName} OriginalPromptCharacters={OriginalPromptCharacters} FinalPromptCharacters={FinalPromptCharacters} OriginalScenes={OriginalScenes} IncludedScenes={IncludedScenes} Limit={Limit}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
character.CharacterID,
character.CharacterName,
originalPromptLength,
minimumPromptLength,
character.Scenes.Count,
scenes.Count,
MaxPromptCharacters);
return minimum;
}
throw new InvalidOperationException($"Character enrichment prompt would exceed the configured safe context limit for character {character.CharacterID} even when limited to {scenes.Count:N0} whole scenes. PromptCharacters={minimumPromptLength:N0} Limit={MaxPromptCharacters:N0}. No manuscript text was silently truncated.");
}
private static CharacterEnrichmentContext BuildContextForCharacters(
CharacterEnrichmentContext context,
IReadOnlyList<CharacterEnrichmentCharacterContext> characters)
=> new()
{
ProjectID = context.ProjectID,
BookID = context.BookID,
BookTitle = context.BookTitle,
StoryEra = context.StoryEra,
SeriesStartDate = context.SeriesStartDate,
Characters = characters
};
private static CharacterEnrichmentCharacterContext CopyCharacter(
CharacterEnrichmentCharacterContext character,
IReadOnlyList<CharacterEnrichmentSceneContext> scenes)
=> new()
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
BirthDate = character.BirthDate,
AgeAtSeriesStart = character.AgeAtSeriesStart,
Height = character.Height,
EyeColour = character.EyeColour,
DefaultDescription = character.DefaultDescription,
Aliases = character.Aliases,
Scenes = scenes
};
private static IReadOnlyList<CharacterEnrichmentSceneContext> SelectRepresentativeScenes(
IReadOnlyList<CharacterEnrichmentSceneContext> scenes,
int maxScenes)
{
if (scenes.Count <= maxScenes)
{
return scenes;
}
maxScenes = Math.Clamp(maxScenes, 1, scenes.Count);
var selected = new SortedDictionary<int, CharacterEnrichmentSceneContext>();
var firstCount = Math.Min(scenes.Count, Math.Min(12, Math.Max(1, maxScenes / 3)));
var lastCount = Math.Min(scenes.Count - firstCount, Math.Min(8, Math.Max(0, maxScenes / 4)));
for (var index = 0; index < firstCount; index++)
{
selected[index] = scenes[index];
}
for (var index = scenes.Count - lastCount; index < scenes.Count; index++)
{
if (index >= 0)
{
selected[index] = scenes[index];
}
}
var remaining = maxScenes - selected.Count;
if (remaining > 0)
{
var start = firstCount;
var endExclusive = scenes.Count - lastCount;
var span = Math.Max(0, endExclusive - start);
for (var slot = 1; slot <= remaining && span > 0; slot++)
{
var offset = (int)Math.Round(slot * (span - 1) / (double)(remaining + 1), MidpointRounding.AwayFromZero);
selected.TryAdd(start + offset, scenes[start + offset]);
}
}
for (var index = 0; selected.Count < maxScenes && index < scenes.Count; index++)
{
selected.TryAdd(index, scenes[index]);
}
return selected.Values.ToList();
}
private async Task PublishProgressAsync(CharacterEnrichmentRun run, int total, int processed, string stage, string message, CancellationToken cancellationToken)
{
await repository.UpdateProgressAsync(new CharacterEnrichmentProgressUpdate
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
TotalCharacters = total,
ProcessedCharacters = processed,
CurrentStage = stage,
CurrentMessage = message
});
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Running, total, processed, stage, message));
}
private static IReadOnlyList<CharacterEnrichmentCharacterResult> FilterResults(
IReadOnlyList<CharacterEnrichmentCharacterResult> results,
HashSet<int> allowedCharacterIds)
=> results
.Where(result => allowedCharacterIds.Contains(result.CharacterID))
.Select(result => new CharacterEnrichmentCharacterResult
{
CharacterID = result.CharacterID,
Summary = Clamp(result.Confidence) >= SummaryMinimumConfidence ? Clean(result.Summary, 2500) : string.Empty,
Confidence = Clamp(result.Confidence),
DateOfBirth = NormaliseDateOfBirth(result.DateOfBirth, result.AgeAtStartOfSeries),
AgeAtStartOfSeries = NormaliseAgeFact(result.AgeAtStartOfSeries),
Sex = NormaliseSexFact(result.Sex),
Height = StrongFact(result.Height),
EyeColour = StrongFact(result.EyeColour),
EvidenceSceneIDs = result.EvidenceSceneIDs.Where(id => id > 0).Distinct().Take(20).ToList()
})
.Where(result => !string.IsNullOrWhiteSpace(result.Summary)
|| !string.IsNullOrWhiteSpace(result.DateOfBirth.Value)
|| !string.IsNullOrWhiteSpace(result.AgeAtStartOfSeries.Value)
|| !string.IsNullOrWhiteSpace(result.Sex.Value)
|| !string.IsNullOrWhiteSpace(result.Height.Value)
|| !string.IsNullOrWhiteSpace(result.EyeColour.Value))
.ToList();
private static CharacterEnrichmentFactResult NormaliseDateOfBirth(
CharacterEnrichmentFactResult dateOfBirth,
CharacterEnrichmentFactResult ageAtStartOfSeries)
{
var direct = StrongFact(dateOfBirth);
if (!string.IsNullOrWhiteSpace(direct.Value))
{
var parsed = ParseDate(direct.Value);
return parsed.HasValue
? CopyFact(direct, parsed.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))
: direct;
}
if (Clamp(ageAtStartOfSeries.Confidence) < FactMinimumConfidence || ageAtStartOfSeries.SceneID is not > 0)
{
return new CharacterEnrichmentFactResult();
}
var source = string.Join(" ", ageAtStartOfSeries.Value, ageAtStartOfSeries.Evidence);
var match = BirthdayAgeDateRegex.Match(source);
if (!match.Success
|| !int.TryParse(match.Groups["age"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var age)
|| age <= 0
|| age > 120)
{
return new CharacterEnrichmentFactResult();
}
var birthday = ParseDate(match.Groups["date"].Value);
if (!birthday.HasValue)
{
return new CharacterEnrichmentFactResult();
}
return new CharacterEnrichmentFactResult
{
Value = birthday.Value.AddYears(-age).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Confidence = Clamp(ageAtStartOfSeries.Confidence),
Evidence = Clean($"Derived from age evidence: {ageAtStartOfSeries.Evidence}", 700),
SceneID = ageAtStartOfSeries.SceneID
};
}
private static CharacterEnrichmentFactResult NormaliseAgeFact(CharacterEnrichmentFactResult fact)
{
var strong = StrongFact(fact);
if (string.IsNullOrWhiteSpace(strong.Value))
{
return strong;
}
var match = LeadingAgeRegex.Match(strong.Value);
if (!match.Success
|| !int.TryParse(match.Groups["age"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var age)
|| age <= 0
|| age > 120)
{
return new CharacterEnrichmentFactResult();
}
return CopyFact(strong, age.ToString(CultureInfo.InvariantCulture));
}
private static CharacterEnrichmentFactResult NormaliseSexFact(CharacterEnrichmentFactResult fact)
{
var strong = StrongFact(fact);
var value = strong.Value?.Trim();
if (string.IsNullOrWhiteSpace(value))
{
return new CharacterEnrichmentFactResult();
}
var normalised = value.Equals("female", StringComparison.OrdinalIgnoreCase) ? "Female"
: value.Equals("male", StringComparison.OrdinalIgnoreCase) ? "Male"
: value.Equals("other", StringComparison.OrdinalIgnoreCase) ? "Other"
: string.Empty;
return string.IsNullOrWhiteSpace(normalised)
? new CharacterEnrichmentFactResult()
: CopyFact(strong, normalised);
}
private static CharacterEnrichmentFactResult CopyFact(CharacterEnrichmentFactResult fact, string value)
=> new()
{
Value = value,
Confidence = fact.Confidence,
Evidence = fact.Evidence,
SceneID = fact.SceneID
};
private static DateTime? ParseDate(string? value)
{
var clean = Regex.Replace(value ?? string.Empty, @"\b(\d{1,2})(st|nd|rd|th)\b", "$1", RegexOptions.IgnoreCase).Trim();
return DateTime.TryParseExact(clean, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|| DateTime.TryParse(clean, CultureInfo.GetCultureInfo("en-GB"), DateTimeStyles.None, out parsed)
? parsed.Date
: null;
}
private static CharacterEnrichmentFactResult StrongFact(CharacterEnrichmentFactResult fact)
=> Clamp(fact.Confidence) >= FactMinimumConfidence && !string.IsNullOrWhiteSpace(fact.Value) && fact.SceneID is > 0
? new CharacterEnrichmentFactResult
{
Value = Clean(fact.Value, 120),
Confidence = Clamp(fact.Confidence),
Evidence = Clean(fact.Evidence, 700),
SceneID = fact.SceneID
}
: new CharacterEnrichmentFactResult();
private static string BuildPrompt(CharacterEnrichmentContext context)
{
var builder = new StringBuilder();
builder.AppendLine("You are PlotDirector's Character Enrichment engine.");
builder.AppendLine("Create concise author-facing character summaries and extract only strongly evidenced character facts.");
builder.AppendLine("Use only the supplied manuscript scenes. Do not invent, infer from behaviour alone, speculate about future events, or list every appearance.");
builder.AppendLine("Do not place ascertainable facts only in the summary. When sex, date of birth, age at start of series, height, or eye colour can be ascertained from direct wording, populate the matching structured field too.");
builder.AppendLine("For sex, return Male, Female, or Other only when direct wording, pronouns, or relationship wording strongly supports it. Otherwise return null value and confidence 0.");
builder.AppendLine("For dateOfBirth, ageAtStartOfSeries, height, and eyeColour, return a value only when direct manuscript wording strongly supports it. Otherwise return null value and confidence 0.");
builder.AppendLine("For ageAtStartOfSeries, prefer a plain integer value such as 18. Put dates or explanation in evidence, not in the value.");
builder.AppendLine("For dateOfBirth, prefer ISO yyyy-MM-dd when directly stated or safely derived from an explicit age and birthday date.");
builder.AppendLine("If a character already has a stored value, preserve it by returning null unless the manuscript contains stronger direct evidence; the save layer will still refuse to overwrite existing author data.");
builder.AppendLine();
builder.AppendLine("[BOOK]");
builder.AppendLine($"BookID: {context.BookID}");
builder.AppendLine($"Title: {context.BookTitle}");
builder.AppendLine($"Story era: {context.StoryEra}");
builder.AppendLine($"Series start date: {context.SeriesStartDate:yyyy-MM-dd}");
builder.AppendLine();
builder.AppendLine("[CHARACTERS]");
foreach (var character in context.Characters)
{
builder.AppendLine();
builder.AppendLine($"CharacterID: {character.CharacterID}");
builder.AppendLine($"Name: {character.CharacterName}");
builder.AppendLine($"Aliases: {string.Join(", ", character.Aliases)}");
builder.AppendLine($"Existing birth date: {character.BirthDate:yyyy-MM-dd}");
builder.AppendLine($"Existing age at series start: {character.AgeAtSeriesStart}");
builder.AppendLine($"Existing height: {character.Height}");
builder.AppendLine($"Existing eye colour: {character.EyeColour}");
builder.AppendLine($"Existing description: {character.DefaultDescription}");
builder.AppendLine("[SCENES]");
foreach (var scene in character.Scenes)
{
builder.AppendLine($"SceneID: {scene.SceneID}; Chapter {scene.ChapterNumber:0.##} {scene.ChapterTitle}; Scene {scene.SceneNumber:0.##} {scene.SceneTitle}");
builder.AppendLine("<text>");
builder.AppendLine(scene.SourceText);
builder.AppendLine("</text>");
}
}
return builder.ToString();
}
private static CharacterEnrichmentProgressEvent ToProgress(CharacterEnrichmentRun run, string status, int total, int processed, string stage, string message, string? error = null)
=> new()
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
CharacterID = run.CharacterID,
Status = status,
TotalCharacters = total,
ProcessedCharacters = processed,
CurrentStage = stage,
CurrentMessage = message,
ErrorMessage = error,
UpdatedUtc = DateTime.UtcNow
};
private static string ExtractOutputText(string rawResponseText)
{
using var response = JsonDocument.Parse(rawResponseText);
foreach (var output in response.RootElement.GetProperty("output").EnumerateArray())
{
if (!output.TryGetProperty("content", out var content))
{
continue;
}
foreach (var item in content.EnumerateArray())
{
if (item.TryGetProperty("type", out var type)
&& string.Equals(type.GetString(), "output_text", StringComparison.OrdinalIgnoreCase)
&& item.TryGetProperty("text", out var text)
&& !string.IsNullOrWhiteSpace(text.GetString()))
{
return text.GetString()!.Trim();
}
}
}
throw new JsonException("OpenAI response did not contain output_text content.");
}
private static decimal Clamp(decimal value)
=> Math.Clamp(value, 0m, 1m);
private static string Clean(string? value, int maxLength)
{
var clean = value?.Trim();
if (string.IsNullOrWhiteSpace(clean))
{
return string.Empty;
}
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd();
}
}
public static class CharacterEnrichmentStructuredOutputSchema
{
public static readonly StoryIntelligenceResponseContract Contract = new()
{
Name = "character_enrichment",
Strict = true,
Schema = new Dictionary<string, object>
{
["type"] = "object",
["additionalProperties"] = false,
["required"] = new[] { "schemaVersion", "characters" },
["properties"] = new Dictionary<string, object>
{
["schemaVersion"] = StringSchema(),
["characters"] = ArraySchema(ObjectSchema(new Dictionary<string, object>
{
["characterID"] = IntegerSchema(),
["summary"] = StringSchema(),
["confidence"] = NumberSchema(),
["dateOfBirth"] = FactSchema(),
["ageAtStartOfSeries"] = FactSchema(),
["sex"] = FactSchema(),
["height"] = FactSchema(),
["eyeColour"] = FactSchema(),
["evidenceSceneIDs"] = ArraySchema(IntegerSchema())
}, "characterID", "summary", "confidence", "dateOfBirth", "ageAtStartOfSeries", "sex", "height", "eyeColour", "evidenceSceneIDs"))
}
}
};
private static Dictionary<string, object> FactSchema()
=> ObjectSchema(new Dictionary<string, object>
{
["value"] = NullableStringSchema(),
["confidence"] = NumberSchema(),
["evidence"] = NullableStringSchema(),
["sceneID"] = NullableIntegerSchema()
}, "value", "confidence", "evidence", "sceneID");
private static Dictionary<string, object> ObjectSchema(Dictionary<string, object> properties, params string[] required)
=> new()
{
["type"] = "object",
["additionalProperties"] = false,
["required"] = required,
["properties"] = properties
};
private static Dictionary<string, object> ArraySchema(object items)
=> new()
{
["type"] = "array",
["items"] = items
};
private static Dictionary<string, object> StringSchema()
=> new() { ["type"] = "string" };
private static Dictionary<string, object> NullableStringSchema()
=> new() { ["type"] = new[] { "string", "null" } };
private static Dictionary<string, object> IntegerSchema()
=> new() { ["type"] = "integer" };
private static Dictionary<string, object> NullableIntegerSchema()
=> new() { ["type"] = new[] { "integer", "null" } };
private static Dictionary<string, object> NumberSchema()
=> new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 };
}