458 lines
21 KiB
C#
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);
|
|
}
|
|
|