PlotDirector/PlotLine/Services/CharacterIntelligenceService.cs

458 lines
21 KiB
C#

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<CharacterIntelligenceService> 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<CharacterIntelligenceProfileUpdateResponse?> TryGetAiUpdatesAsync(
StoryIntelligenceBookPipelineState importSession,
int sceneResultId,
SceneIntelligenceScene scene,
IReadOnlyList<CharacterIntelligenceProfile> 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<CharacterIntelligenceProfileUpdateResponse>(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<CharacterIntelligenceProfileDocument>(profile.ProfileJson, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
private static CharacterIntelligenceProfileDocument NewProfile(string canonicalName, IEnumerable<string>? aliases)
=> new()
{
CanonicalName = Clean(canonicalName),
Aliases = CleanList(aliases),
Confidence = 0.25m,
LastUpdatedUtc = DateTime.UtcNow
};
private static IReadOnlyList<CharacterIntelligenceEvidenceSave> ApplyUpdate(
CharacterIntelligenceProfileDocument profile,
CharacterIntelligenceProfileUpdate update,
int sceneResultId,
SceneIntelligenceScene scene)
{
var events = new List<CharacterIntelligenceEvidenceSave>();
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<CharacterIntelligenceProfileUpdate>();
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<CharacterIntelligenceFieldUpdate>
{
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<string> 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<CharacterIntelligenceProfile> 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<StoryMemoryCharacter> characters,
IReadOnlyList<StoryMemoryCharacterAttribute> 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<string>? 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<string>? 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<string> target, IEnumerable<string>? values)
{
foreach (var value in CleanList(values))
{
AddUnique(target, value);
}
}
private static void AddUnique(List<string> target, string? value)
{
var clean = Clean(value);
if (!string.IsNullOrWhiteSpace(clean) && !target.Contains(clean, StringComparer.OrdinalIgnoreCase))
{
target.Add(clean);
}
}
private static List<string> CleanList(IEnumerable<string>? 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);
}