473 lines
19 KiB
C#
473 lines
19 KiB
C#
using System.Text.Json;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IStoryIntelligenceCharacterImportService
|
|
{
|
|
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
|
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form);
|
|
}
|
|
|
|
public sealed class StoryIntelligenceCharacterImportService(
|
|
IStoryIntelligenceResultRepository runs,
|
|
ICharacterRepository characters,
|
|
ISceneRepository scenes,
|
|
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private static readonly HashSet<string> IgnoredNames = CreateSet("narrator", "unknown");
|
|
|
|
private static readonly HashSet<string> GenericPeople = CreateSet(
|
|
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
|
|
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
|
|
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group");
|
|
|
|
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
|
{
|
|
var data = await BuildCandidateDataAsync(batch);
|
|
return new StoryIntelligenceCharacterReviewViewModel
|
|
{
|
|
HasCommittedScenes = data.HasCommittedScenes,
|
|
CanImport = data.Candidates.Count > 0,
|
|
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
|
Candidates = data.Candidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
|
{
|
|
Key = candidate.Key,
|
|
CharacterName = candidate.DisplayName,
|
|
ImportName = candidate.DisplayName,
|
|
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
|
Confidence = ConfidenceBand(candidate.Confidence),
|
|
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
|
|
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
|
ExistingCharacterID = candidate.ExistingCharacterID,
|
|
ExistingCharacterName = candidate.ExistingCharacterName
|
|
}).ToList()
|
|
};
|
|
}
|
|
|
|
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form)
|
|
{
|
|
var data = await BuildCandidateDataAsync(batch);
|
|
if (!data.HasCommittedScenes)
|
|
{
|
|
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." };
|
|
}
|
|
|
|
var choices = form.Characters
|
|
.Where(choice => choice.Include && !string.IsNullOrWhiteSpace(choice.Key))
|
|
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
|
|
if (choices.Count == 0)
|
|
{
|
|
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create or link." };
|
|
}
|
|
|
|
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
|
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
|
var roleTypes = lookupData.RoleTypes;
|
|
var presenceTypes = lookupData.PresenceTypes;
|
|
var created = 0;
|
|
var linkedAppearances = 0;
|
|
var povLinks = 0;
|
|
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var candidate in data.Candidates)
|
|
{
|
|
if (!choices.TryGetValue(candidate.Key, out var choice))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var requestedName = Clean(choice.ImportName);
|
|
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
|
var characterId = candidate.ExistingCharacterID
|
|
?? existingIndex.Find(importName)?.CharacterID
|
|
?? existingIndex.Find(candidate.DisplayName)?.CharacterID;
|
|
|
|
if (!characterId.HasValue)
|
|
{
|
|
characterId = await characters.SaveCharacterAsync(new Character
|
|
{
|
|
ProjectID = batch.ProjectID,
|
|
CharacterName = importName,
|
|
ShortName = FirstName(importName),
|
|
DefaultDescription = candidate.Description
|
|
});
|
|
created++;
|
|
}
|
|
|
|
AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value);
|
|
AddResolvedName(resolvedCharacters, importName, characterId.Value);
|
|
foreach (var alias in candidate.Aliases)
|
|
{
|
|
AddResolvedName(resolvedCharacters, alias, characterId.Value);
|
|
await TryAddAliasAsync(characterId.Value, alias, importName);
|
|
}
|
|
|
|
foreach (var appearance in candidate.PresentAppearances)
|
|
{
|
|
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
|
{
|
|
SceneID = appearance.SceneID,
|
|
CharacterID = characterId.Value,
|
|
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
|
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
|
AppearanceNotes = appearance.Notes
|
|
});
|
|
linkedAppearances++;
|
|
}
|
|
}
|
|
|
|
foreach (var scene in data.SceneAnalyses)
|
|
{
|
|
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
|
|
if (string.IsNullOrWhiteSpace(povName) || !resolvedCharacters.TryGetValue(povName, out var characterId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
await scenes.UpdatePovCharacterAsync(scene.SceneID, characterId);
|
|
povLinks++;
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
|
|
batch.BatchID,
|
|
created,
|
|
linkedAppearances,
|
|
povLinks);
|
|
|
|
return new StoryIntelligenceImportCommitResult
|
|
{
|
|
Success = true,
|
|
ScenesCreated = linkedAppearances,
|
|
Message = $"Characters imported. {created:N0} character(s) created, {linkedAppearances:N0} scene appearance(s) linked, {povLinks:N0} POV link(s) updated."
|
|
};
|
|
}
|
|
|
|
private async Task<CharacterCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
|
|
{
|
|
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
|
var groups = new Dictionary<string, CharacterCandidate>(StringComparer.OrdinalIgnoreCase);
|
|
var sceneAnalyses = new List<ImportedSceneAnalysis>();
|
|
var alreadyLinked = 0;
|
|
var hasCommittedScenes = false;
|
|
|
|
foreach (var item in batch.Items)
|
|
{
|
|
var commit = await runs.GetImportCommitAsync(item.RunID);
|
|
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
hasCommittedScenes = true;
|
|
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
|
.Where(scene => scene.ImportRunID == item.RunID)
|
|
.ToList();
|
|
var importedByRange = importedScenes
|
|
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
|
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
|
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
|
|
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
|
|
|
|
foreach (var sceneResult in sceneResults)
|
|
{
|
|
var parsed = TryReadScene(sceneResult);
|
|
if (parsed is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
|
if (importedScene is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
sceneAnalyses.Add(new ImportedSceneAnalysis(importedScene.SceneID, parsed));
|
|
var existingAppearances = await characters.ListSceneCharactersAsync(importedScene.SceneID);
|
|
alreadyLinked += existingAppearances.Count;
|
|
|
|
foreach (var character in parsed.Characters ?? [])
|
|
{
|
|
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
|
|
}
|
|
}
|
|
}
|
|
|
|
var candidates = groups.Values
|
|
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
|
|
.Where(candidate => candidate.PresentAppearances.Any(appearance => !appearance.AlreadyLinked))
|
|
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
|
.ThenBy(candidate => candidate.DisplayName)
|
|
.ToList();
|
|
|
|
return new CharacterCandidateData(hasCommittedScenes, alreadyLinked, candidates, sceneAnalyses);
|
|
}
|
|
|
|
private static void AddMention(
|
|
Dictionary<string, CharacterCandidate> groups,
|
|
CharacterIndex existingIndex,
|
|
Scene importedScene,
|
|
SceneIntelligenceScene parsed,
|
|
SceneIntelligenceCharacter character,
|
|
IReadOnlyList<SceneCharacter> existingAppearances)
|
|
{
|
|
var name = Clean(character.Name);
|
|
if (string.IsNullOrWhiteSpace(name) || IgnoredNames.Contains(name) || IsGenericPerson(name))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var key = Normalise(name);
|
|
if (!groups.TryGetValue(key, out var candidate))
|
|
{
|
|
var match = existingIndex.Find(name);
|
|
candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName);
|
|
groups[key] = candidate;
|
|
}
|
|
|
|
foreach (var alias in character.Aliases ?? [])
|
|
{
|
|
var cleanAlias = Clean(alias);
|
|
if (!string.IsNullOrWhiteSpace(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
candidate.Aliases.Add(cleanAlias);
|
|
}
|
|
}
|
|
|
|
var notes = Clean(character.Notes);
|
|
if (string.IsNullOrWhiteSpace(notes) && character.Actions?.Count > 0)
|
|
{
|
|
notes = Clean(string.Join("; ", character.Actions));
|
|
}
|
|
|
|
var appearance = new CharacterAppearanceImport(
|
|
importedScene.SceneID,
|
|
importedScene.SceneNumber,
|
|
importedScene.SceneTitle,
|
|
character.RoleInScene,
|
|
character.MentionedOnly == true,
|
|
notes,
|
|
character.Confidence,
|
|
candidate.ExistingCharacterID.HasValue && existingAppearances.Any(appearance => appearance.CharacterID == candidate.ExistingCharacterID.Value));
|
|
if (character.MentionedOnly == true)
|
|
{
|
|
candidate.MentionedOnlyAppearances.Add(appearance);
|
|
}
|
|
else
|
|
{
|
|
candidate.PresentAppearances.Add(appearance);
|
|
}
|
|
|
|
candidate.Confidence = Max(candidate.Confidence, character.Confidence);
|
|
candidate.Description ??= notes;
|
|
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, character);
|
|
}
|
|
|
|
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
|
|
{
|
|
var index = new CharacterIndex();
|
|
foreach (var character in await characters.ListCharactersAsync(projectId))
|
|
{
|
|
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
|
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
|
|
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
|
{
|
|
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
|
|
}
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
private async Task TryAddAliasAsync(int characterId, string alias, string primaryName)
|
|
{
|
|
var cleanAlias = Clean(alias);
|
|
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var existingAliases = await characters.ListAliasesAsync(characterId);
|
|
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await characters.AddAliasAsync(characterId, cleanAlias, null);
|
|
}
|
|
|
|
private static Scene? ResolveImportedScene(
|
|
StoryIntelligenceSavedSceneResult sceneResult,
|
|
IReadOnlyDictionary<string, Scene> byRange,
|
|
IReadOnlyDictionary<int, Scene> byNumber)
|
|
{
|
|
if (sceneResult.StartParagraph.HasValue
|
|
&& sceneResult.EndParagraph.HasValue
|
|
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
|
{
|
|
return rangeMatch;
|
|
}
|
|
|
|
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
|
|
}
|
|
|
|
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(result.ParsedJson))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
|
|
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
|
|
{
|
|
return direct;
|
|
}
|
|
|
|
using var document = JsonDocument.Parse(result.ParsedJson);
|
|
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
|
|
{
|
|
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static int? MatchRole(IReadOnlyList<CharacterRoleInSceneType> roleTypes, string? role)
|
|
{
|
|
var value = Clean(role);
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return roleTypes.FirstOrDefault(type =>
|
|
value.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase)
|
|
|| type.TypeName.Contains(value, StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID;
|
|
}
|
|
|
|
private static int? MatchPresence(IReadOnlyList<PresenceType> presenceTypes, bool mentionedOnly)
|
|
{
|
|
var preferred = mentionedOnly ? "Mentioned" : "Present";
|
|
return presenceTypes.FirstOrDefault(type => type.TypeName.Contains(preferred, StringComparison.OrdinalIgnoreCase))?.PresenceTypeID;
|
|
}
|
|
|
|
private static void AddResolvedName(Dictionary<string, int> map, string name, int characterId)
|
|
{
|
|
var clean = Clean(name);
|
|
if (!string.IsNullOrWhiteSpace(clean))
|
|
{
|
|
map[clean] = characterId;
|
|
}
|
|
}
|
|
|
|
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
|
|
{
|
|
var note = Clean(character.Notes);
|
|
if (!string.IsNullOrWhiteSpace(note))
|
|
{
|
|
return $"Scene {scene.SceneNumber:g}: {note}";
|
|
}
|
|
|
|
var summary = Clean(parsed.Summary?.Short);
|
|
return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}";
|
|
}
|
|
|
|
private static decimal? Max(decimal? current, decimal? next)
|
|
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
|
|
|
|
private static string ConfidenceBand(decimal? confidence)
|
|
=> confidence switch
|
|
{
|
|
>= 0.75m => "High",
|
|
>= 0.45m => "Medium",
|
|
null => "Unknown",
|
|
_ => "Low"
|
|
};
|
|
|
|
private static string? FirstName(string name)
|
|
{
|
|
var parts = Clean(name).Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
return parts.Length > 1 ? parts[0] : null;
|
|
}
|
|
|
|
private static bool IsGenericPerson(string name)
|
|
=> GenericPeople.Contains(name) || GenericPeople.Contains(Normalise(name));
|
|
|
|
private static string Normalise(string? value)
|
|
=> Clean(value).ToLowerInvariant();
|
|
|
|
private static string Clean(string? value)
|
|
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
|
|
|
private static HashSet<string> CreateSet(params string[] values)
|
|
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private sealed class CharacterIndex
|
|
{
|
|
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public void Add(string? name, int characterId, string characterName)
|
|
{
|
|
var key = Normalise(name);
|
|
if (!string.IsNullOrWhiteSpace(key))
|
|
{
|
|
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
|
|
}
|
|
}
|
|
|
|
public CharacterMatch? Find(string name)
|
|
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
|
|
}
|
|
|
|
private sealed record CharacterMatch(int CharacterID, string CharacterName);
|
|
|
|
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
|
|
{
|
|
public string Key { get; } = key;
|
|
public string DisplayName { get; } = displayName;
|
|
public int? ExistingCharacterID { get; } = existingCharacterId;
|
|
public string? ExistingCharacterName { get; } = existingCharacterName;
|
|
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public List<CharacterAppearanceImport> PresentAppearances { get; } = [];
|
|
public List<CharacterAppearanceImport> MentionedOnlyAppearances { get; } = [];
|
|
public decimal? Confidence { get; set; }
|
|
public string? Description { get; set; }
|
|
public string? FirstAppearanceNote { get; set; }
|
|
}
|
|
|
|
private sealed record CharacterAppearanceImport(
|
|
int SceneID,
|
|
decimal SceneNumber,
|
|
string SceneTitle,
|
|
string? RoleInScene,
|
|
bool MentionedOnly,
|
|
string? Notes,
|
|
decimal? Confidence,
|
|
bool AlreadyLinked);
|
|
|
|
private sealed record ImportedSceneAnalysis(int SceneID, SceneIntelligenceScene Parsed);
|
|
|
|
private sealed record CharacterCandidateData(
|
|
bool HasCommittedScenes,
|
|
int AlreadyLinkedCount,
|
|
IReadOnlyList<CharacterCandidate> Candidates,
|
|
IReadOnlyList<ImportedSceneAnalysis> SceneAnalyses);
|
|
}
|