using System.Text.Json; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface ICharacterIntelligenceService { Task UpdateProfilesForSceneAsync( StoryIntelligenceBookPipelineState importSession, int sceneResultId, SceneIntelligenceScene scene, CancellationToken cancellationToken = default); } public sealed class CharacterIntelligenceService( ICharacterIntelligenceRepository profiles, IStoryMemoryRepository storyMemory, IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryIntelligenceClient client, ILogger logger) : ICharacterIntelligenceService { private const string PromptFile = "Character-Profile-Update-Prompt-V1.md"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = false }; public async Task UpdateProfilesForSceneAsync( StoryIntelligenceBookPipelineState importSession, int sceneResultId, SceneIntelligenceScene scene, CancellationToken cancellationToken = default) { var characters = SceneCharacterNames(scene).ToList(); if (characters.Count == 0) { return; } var existingProfiles = await profiles.ListProfilesAsync(importSession.StoryIntelligenceBookPipelineID); var response = await TryGetAiUpdatesAsync(importSession, sceneResultId, scene, existingProfiles, cancellationToken) ?? BuildStructuredFallbackUpdates(scene); foreach (var update in response.Updates.Where(item => !string.IsNullOrWhiteSpace(item.CanonicalName))) { cancellationToken.ThrowIfCancellationRequested(); var key = CharacterKey(update.CanonicalName, update.Aliases); var existing = existingProfiles.FirstOrDefault(item => string.Equals(item.CanonicalIdentityKey, key, StringComparison.OrdinalIgnoreCase)); var document = ReadProfile(existing) ?? NewProfile(update.CanonicalName, update.Aliases); var events = ApplyUpdate(document, update, sceneResultId, scene); var saved = await profiles.UpsertProfileAsync(new CharacterIntelligenceProfileSave( importSession.StoryIntelligenceBookPipelineID, importSession.ProjectID, importSession.BookID, key, document.CanonicalName, JsonSerializer.Serialize(document, JsonOptions), document.Confidence, document.NarrativeImportance, IsRecurring(document, scene), CharacterIntelligenceVersions.Current, sceneResultId)); foreach (var item in events) { await profiles.AddEvidenceAsync(item with { CharacterIntelligenceProfileID = saved.CharacterIntelligenceProfileID }); } } } private async Task TryGetAiUpdatesAsync( StoryIntelligenceBookPipelineState importSession, int sceneResultId, SceneIntelligenceScene scene, IReadOnlyList existingProfiles, CancellationToken cancellationToken) { try { var memoryCharacters = await storyMemory.ListCharactersAsync(importSession.StoryIntelligenceBookPipelineID); var memoryAttributes = await storyMemory.ListCharacterAttributesAsync(importSession.StoryIntelligenceBookPipelineID); var prompt = await prompts.LoadPromptAsync(PromptFile, cancellationToken); var completed = prompt .Replace("{{CURRENT_CHARACTER_PROFILES_JSON}}", BuildCurrentProfilesJson(existingProfiles), StringComparison.Ordinal) .Replace("{{CURRENT_SCENE_INTELLIGENCE_JSON}}", JsonSerializer.Serialize(scene, JsonOptions), StringComparison.Ordinal) .Replace("{{CURRENT_STORY_MEMORY_JSON}}", BuildStoryMemoryJson(memoryCharacters, memoryAttributes), StringComparison.Ordinal) .Replace("{{SCENE_RESULT_ID}}", sceneResultId.ToString("N0"), StringComparison.Ordinal); var clientResult = await client.ExecutePromptAsync( completed, versions.GetPromptVersion(PromptFile), cancellationToken, client.GetConfigurationStatus().SceneIntelligenceModel); var output = ExtractOutputText(clientResult.RawResponseText); var parse = StoryIntelligenceAiJsonParser.Parse(output, JsonOptions); if (parse.Success && parse.Parsed is not null) { return parse.Parsed; } logger.LogWarning( "Character Intelligence profile update JSON could not be parsed for import session {ImportSessionId}, scene result {SceneResultId}: {ErrorMessage}", importSession.StoryIntelligenceBookPipelineID, sceneResultId, parse.ErrorMessage); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning( ex, "Character Intelligence AI profile update failed for import session {ImportSessionId}, scene result {SceneResultId}. Structured scene output fallback will be used.", importSession.StoryIntelligenceBookPipelineID, sceneResultId); } return null; } private static CharacterIntelligenceProfileDocument? ReadProfile(CharacterIntelligenceProfile? profile) { if (profile is null || string.IsNullOrWhiteSpace(profile.ProfileJson)) { return null; } try { return JsonSerializer.Deserialize(profile.ProfileJson, JsonOptions); } catch (JsonException) { return null; } } private static CharacterIntelligenceProfileDocument NewProfile(string canonicalName, IEnumerable? aliases) => new() { CanonicalName = Clean(canonicalName), Aliases = CleanList(aliases), Confidence = 0.25m, LastUpdatedUtc = DateTime.UtcNow }; private static IReadOnlyList ApplyUpdate( CharacterIntelligenceProfileDocument profile, CharacterIntelligenceProfileUpdate update, int sceneResultId, SceneIntelligenceScene scene) { var events = new List(); profile.CanonicalName = FirstConfigured(profile.CanonicalName, update.CanonicalName); MergeList(profile.Aliases, update.Aliases); profile.Confidence = Math.Max(profile.Confidence, ClampConfidence(update.Confidence)); foreach (var field in update.FieldUpdates.Where(item => !string.IsNullOrWhiteSpace(item.FieldName) && !string.IsNullOrWhiteSpace(item.Value))) { var previous = GetField(profile, field.FieldName); if (!ShouldApply(previous, field)) { continue; } SetField(profile, field.FieldName, field.Value); var confidence = ClampConfidence(field.Confidence); profile.Confidence = Math.Max(profile.Confidence, confidence); profile.EvidenceTimeline.Add(new CharacterIntelligenceEvidenceItem { SceneResultID = sceneResultId, ChapterNumber = scene.SceneReference?.ChapterNumber, SceneNumber = scene.SceneReference?.SceneNumber is decimal number ? Convert.ToInt32(number) : null, FieldName = field.FieldName, Value = field.Value, Evidence = Clean(field.Evidence), Confidence = confidence }); events.Add(NewEvidence(sceneResultId, field.FieldName, previous, field.Value, field.Evidence, confidence, false, "AI profile update")); } foreach (var contradiction in update.Contradictions.Where(item => !string.IsNullOrWhiteSpace(item.FieldName))) { profile.Contradictions.Add(new CharacterIntelligenceContradiction { FieldName = contradiction.FieldName, ExistingValue = contradiction.ExistingValue, NewValue = contradiction.NewValue, Evidence = Clean(contradiction.Evidence), Confidence = ClampConfidence(contradiction.Confidence), SceneResultID = sceneResultId }); events.Add(NewEvidence(sceneResultId, contradiction.FieldName, contradiction.ExistingValue, contradiction.NewValue, contradiction.Evidence, ClampConfidence(contradiction.Confidence), true, "AI profile update")); } profile.EvidenceTimeline = profile.EvidenceTimeline.TakeLast(80).ToList(); profile.Contradictions = profile.Contradictions.TakeLast(40).ToList(); profile.LastUpdatedUtc = DateTime.UtcNow; return events; } private static CharacterIntelligenceEvidenceSave NewEvidence( int sceneResultId, string fieldName, string? previous, string? value, string evidence, decimal confidence, bool contradiction, string source) => new(0, sceneResultId, fieldName, previous, value, Clean(evidence), confidence, contradiction, source); private static CharacterIntelligenceProfileUpdateResponse BuildStructuredFallbackUpdates(SceneIntelligenceScene scene) { var updates = new List(); foreach (var group in (scene.Characters ?? []) .Where(item => !string.IsNullOrWhiteSpace(item.Name)) .GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase)) { var character = group.OrderByDescending(item => item.Confidence ?? 0).First(); var appearance = (scene.CharacterAppearance ?? []) .Where(item => !string.IsNullOrWhiteSpace(item.CanonicalName)) .FirstOrDefault(item => SameCharacter(item.CanonicalName, character.Name, item.Aliases)); var fields = new List { Field("RoleInStory", character.RoleInScene, character.Notes, character.Confidence), Field("Presentation", appearance?.Presentation ?? appearance?.PronounEvidence, appearance?.Evidence, appearance?.Confidence), Field("EstimatedAge", appearance?.ExplicitAge, appearance?.Evidence, appearance?.Confidence), Field("EstimatedAgeBand", appearance?.InferredAgeBand, appearance?.Evidence, appearance?.Confidence), Field("HairColour", appearance?.HairColour, appearance?.Evidence, appearance?.Confidence), Field("HairLength", appearance?.HairLength, appearance?.Evidence, appearance?.Confidence), Field("HairStyle", appearance?.HairStyle, appearance?.Evidence, appearance?.Confidence), Field("EyeColour", appearance?.EyeColour, appearance?.Evidence, appearance?.Confidence), Field("Height", appearance?.Height, appearance?.Evidence, appearance?.Confidence), Field("Build", appearance?.Build, appearance?.Evidence, appearance?.Confidence), Field("TypicalClothing", appearance?.Clothing, appearance?.Evidence, appearance?.Confidence), Field("Occupation", appearance?.RoleOrOccupation, appearance?.Evidence, appearance?.Confidence), Field("CurrentStatus", character.MentionedOnly == true ? "Mentioned" : "Present in scene", character.Notes, character.Confidence) }; updates.Add(new CharacterIntelligenceProfileUpdate { CanonicalName = group.Key, Aliases = CleanList(character.Aliases).Concat(CleanList(appearance?.Aliases)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), Confidence = character.Confidence ?? appearance?.Confidence ?? 0.55m, FieldUpdates = fields.Where(item => !string.IsNullOrWhiteSpace(item.Value)).ToList() }); } return new CharacterIntelligenceProfileUpdateResponse { Updates = updates }; } private static CharacterIntelligenceFieldUpdate Field(string name, string? value, string? evidence, decimal? confidence) => new() { FieldName = name, Value = Clean(value), Evidence = Clean(evidence), Confidence = confidence ?? 0.55m, IsNewInformation = true, IncreasesCertainty = true }; private static bool ShouldApply(string? previous, CharacterIntelligenceFieldUpdate update) { if (string.IsNullOrWhiteSpace(update.Value)) { return false; } if (string.IsNullOrWhiteSpace(previous) || string.Equals(previous, "Unknown", StringComparison.OrdinalIgnoreCase)) { return true; } return update.IncreasesCertainty && ClampConfidence(update.Confidence) >= 0.75m && !string.Equals(previous, update.Value, StringComparison.OrdinalIgnoreCase); } private static string? GetField(CharacterIntelligenceProfileDocument profile, string fieldName) => NormaliseField(fieldName) switch { "roleinstory" => profile.RoleInStory, "narrativeimportance" => profile.NarrativeImportance, "presentation" => profile.Presentation, "estimatedage" => profile.EstimatedAge, "estimatedageband" => profile.EstimatedAgeBand, "haircolour" => profile.HairColour, "hairlength" => profile.HairLength, "hairstyle" => profile.HairStyle, "eyecolour" => profile.EyeColour, "skintone" => profile.SkinTone, "height" => profile.Height, "build" => profile.Build, "typicalclothing" => profile.TypicalClothing, "occupation" => profile.Occupation, "currentstatus" => profile.CurrentStatus, _ => null }; private static void SetField(CharacterIntelligenceProfileDocument profile, string fieldName, string? value) { var clean = Clean(value); switch (NormaliseField(fieldName)) { case "roleinstory": profile.RoleInStory = clean; break; case "narrativeimportance": profile.NarrativeImportance = clean; break; case "presentation": profile.Presentation = clean; break; case "estimatedage": profile.EstimatedAge = clean; break; case "estimatedageband": profile.EstimatedAgeBand = clean; break; case "haircolour": profile.HairColour = clean; break; case "hairlength": profile.HairLength = clean; break; case "hairstyle": profile.HairStyle = clean; break; case "eyecolour": profile.EyeColour = clean; break; case "skintone": profile.SkinTone = clean; break; case "height": profile.Height = clean; break; case "build": profile.Build = clean; break; case "typicalclothing": profile.TypicalClothing = clean; break; case "occupation": profile.Occupation = clean; break; case "currentstatus": profile.CurrentStatus = clean; break; case "distinctivefeatures": AddUnique(profile.DistinctiveFeatures, clean); break; case "relationships": AddUnique(profile.Relationships, clean); break; } } private static bool IsRecurring(CharacterIntelligenceProfileDocument profile, SceneIntelligenceScene scene) => profile.EvidenceTimeline.Select(item => item.SceneResultID).Distinct().Count() > 1 || (scene.Relationships ?? []).Any(item => string.Equals(Clean(item.CharacterA), profile.CanonicalName, StringComparison.OrdinalIgnoreCase) || string.Equals(Clean(item.CharacterB), profile.CanonicalName, StringComparison.OrdinalIgnoreCase)) || profile.Confidence >= 0.7m; private static IEnumerable SceneCharacterNames(SceneIntelligenceScene scene) => (scene.Characters ?? []).Select(item => item.Name) .Concat((scene.CharacterAppearance ?? []).Select(item => item.CanonicalName)) .Where(item => !string.IsNullOrWhiteSpace(item)) .Select(item => Clean(item)!); private static string BuildCurrentProfilesJson(IReadOnlyList existingProfiles) => JsonSerializer.Serialize(existingProfiles.Select(item => new { item.CanonicalIdentityKey, item.CanonicalName, item.Confidence, item.NarrativeImportance, item.IsRecurring, profile = ReadProfile(item) }), JsonOptions); private static string BuildStoryMemoryJson( IReadOnlyList characters, IReadOnlyList attributes) => JsonSerializer.Serialize(new { characters = characters.Select(item => new { item.CanonicalIdentityKey, item.DisplayName, item.IsNamed, item.IsGroupEntity, item.IsNarrator, item.IsPrimaryPOV, item.Significance, attributes = attributes .Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID) .Select(attribute => new { attribute.AttributeType, attribute.NormalisedValue, attribute.Confidence, attribute.IsExplicit, attribute.EvidenceSummary }) }) }, JsonOptions); 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(); } } } return string.Empty; } private static bool SameCharacter(string? value, string? name, IEnumerable? aliases) { var clean = Clean(value); return string.Equals(clean, Clean(name), StringComparison.OrdinalIgnoreCase) || CleanList(aliases).Any(alias => string.Equals(alias, clean, StringComparison.OrdinalIgnoreCase)); } private static string CharacterKey(string name, IEnumerable? aliases) { var candidates = CleanList(aliases).Prepend(Clean(name)).Where(item => !string.IsNullOrWhiteSpace(item)).ToList(); var selected = candidates.OrderBy(item => item.Length).FirstOrDefault() ?? "unknown"; return $"character-intelligence-{StableKey(selected)}"; } private static string StableKey(string? value) { var chars = (value ?? string.Empty).Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray(); return string.Join('-', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries)); } private static string NormaliseField(string value) => new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); private static void MergeList(List target, IEnumerable? values) { foreach (var value in CleanList(values)) { AddUnique(target, value); } } private static void AddUnique(List target, string? value) { var clean = Clean(value); if (!string.IsNullOrWhiteSpace(clean) && !target.Contains(clean, StringComparer.OrdinalIgnoreCase)) { target.Add(clean); } } private static List CleanList(IEnumerable? values) => (values ?? []) .Select(Clean) .Where(item => !string.IsNullOrWhiteSpace(item)) .Select(item => item!) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); private static string Clean(string? value) => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); private static string FirstConfigured(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; private static decimal ClampConfidence(decimal confidence) => Math.Clamp(confidence <= 0 ? 0.55m : confidence, 0m, 1m); }