PlotDirector/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs

2524 lines
124 KiB
C#

using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceVisualisationSnapshotService
{
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId);
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId);
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildReplaySnapshotAsync(int importSessionId, int userId);
Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId);
Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId);
}
public sealed class StoryIntelligenceVisualisationSnapshotService(
IStoryIntelligenceResultRepository repository,
IStoryIntelligencePipelineRepository pipelines,
IStoryIntelligenceSourceRepository sources,
IIllustrationLibraryService illustrationLibrary,
IStoryMemoryRepository storyMemoryRepository,
IIllustrationLibraryRepository illustrationRepository,
IUploadStorageService uploadStorage,
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
{
private const string FallbackRoot = "/images/story-intelligence/prototype";
private const int SceneWindowPrevious = 4;
private const int SceneWindowFollowing = 2;
private const int SnapshotWarningBytes = 1024 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId)
{
var runs = await repository.ListRunsAsync();
return runs
.Where(run => run.UserID == userId)
.OrderByDescending(run => run.CreatedUtc)
.Take(12)
.Select(run => new StoryIntelligenceExperienceRunOption
{
RunId = run.StoryIntelligenceRunID,
Status = run.Status,
CreatedUtc = run.CreatedUtc,
Label = $"{DisplayTitle(run.ProjectTitle, "Project")} / {DisplayTitle(run.BookTitle, "Book")} - {run.Status} - {run.CreatedUtc:u}"
})
.ToList();
}
public async Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId)
{
await EnsureActiveImportSessionsAsync(userId);
var sessions = await pipelines.ListForUserAsync(userId);
return sessions
.OrderByDescending(session => session.UpdatedUtc)
.Take(12)
.Select(session => new StoryIntelligenceExperienceRunOption
{
ImportSessionId = session.StoryIntelligenceBookPipelineID,
RunId = session.LastRunID ?? 0,
Status = session.Status,
CreatedUtc = session.CreatedUtc,
Label = $"{DisplayTitle(session.ProjectName, "Project")} / {DisplayTitle(session.BookDisplayTitle, "Book")} - {session.Status} - started {session.CreatedUtc:u}"
})
.ToList();
}
private async Task EnsureActiveImportSessionsAsync(int userId)
{
var runs = await repository.ListRunsAsync();
var activeBookRuns = runs
.Where(run => run.UserID == userId
&& run.ProjectID.HasValue
&& run.BookID.HasValue
&& !IsTerminal(run.Status))
.GroupBy(run => new { ProjectID = run.ProjectID!.Value, BookID = run.BookID!.Value })
.Select(group => group.OrderByDescending(run => run.CreatedUtc).First())
.ToList();
foreach (var run in activeBookRuns)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = run.ProjectID!.Value,
BookID = run.BookID!.Value,
CurrentStage = StoryIntelligencePipelineStages.Chapters,
LastCompletedStage = null,
CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.InProgress,
CompletedUtc = null,
LastRunID = run.StoryIntelligenceRunID
});
}
}
public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId)
{
var stopwatch = Stopwatch.StartNew();
var run = await repository.GetRunAsync(runId);
if (run is null || run.UserID != userId)
{
return null;
}
var results = (await repository.ListSceneResultsAsync(runId)).ToList();
var parsed = results
.Select(result => ReadSceneResult(runId, result))
.OrderBy(item => SceneSortChapter(item))
.ThenBy(item => SceneSortNumber(item))
.ThenBy(item => item.Result.SceneResultID)
.ToList();
var completed = parsed
.Where(item => item.Scene is not null)
.ToList();
var current = PickCurrentScene(run, completed);
var storyMemory = new StoryMemory();
var visibleSceneIds = VisibleSceneResultIds(completed, current, SceneWindowPrevious, SceneWindowFollowing);
var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory, includedSceneResultIds: visibleSceneIds).ToList();
if (scenes.Count == 0)
{
scenes.Add(BuildEmptyScene(run));
}
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "real",
RunId = run.StoryIntelligenceRunID,
BookId = run.BookID,
RunStatus = run.Status,
GeneratedUtc = DateTime.UtcNow,
ChangeToken = ChangeToken(run, results),
IsTerminal = IsTerminal(run.Status),
SnapshotMessage = SnapshotMessage(run, completed.Count),
Diagnostics = BuildDiagnostics(run, results, parsed, completed, current, storyMemory),
ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"),
BookTitle = DisplayTitle(run.BookTitle, "Untitled book"),
Scenes = scenes
};
StabiliseCharacterIllustrations(model);
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
ApplyIllustrationDiagnostics(model);
StripLiveDiagnostics(model);
LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds);
return model;
}
public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId)
=> await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: false);
public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildReplaySnapshotAsync(int importSessionId, int userId)
=> await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: true);
private async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId, bool replayMode)
{
var stopwatch = Stopwatch.StartNew();
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId);
if (session is null)
{
return null;
}
var runs = (await repository.ListRunsByBookForUserAsync(session.BookID, userId))
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(run => run.ChapterID ?? int.MaxValue)
.ThenBy(run => run.StoryIntelligenceRunID)
.ToList();
if (runs.Count == 0)
{
return null;
}
var runSnapshots = new List<RunSnapshot>();
foreach (var run in runs)
{
var results = (await repository.ListSceneResultsAsync(run.StoryIntelligenceRunID)).ToList();
var parsed = results
.Select(result => ReadSceneResult(run.StoryIntelligenceRunID, result))
.OrderBy(item => SceneSortChapter(item))
.ThenBy(item => SceneSortNumber(item))
.ThenBy(item => item.Result.SceneResultID)
.ToList();
var completed = parsed.Where(item => item.Scene is not null).ToList();
runSnapshots.Add(new(run, results, parsed, completed, PickCurrentScene(run, completed)));
}
var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last();
var allCompleted = runSnapshots
.SelectMany(snapshot => snapshot.Completed)
.OrderBy(item => SceneSortChapter(item))
.ThenBy(item => SceneSortNumber(item))
.ThenBy(item => item.Result.SceneResultID)
.ToList();
var current = replayMode
? allCompleted.FirstOrDefault()
: PickAuthoritativeCurrentScene(runSnapshots);
var currentSnapshot = OwnerOf(runSnapshots, current) ?? activeSnapshot;
var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count);
var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count);
var totalScenes = Math.Max(
runSnapshots.Sum(snapshot => Math.Max(snapshot.Run.TotalDetectedScenes ?? 0, snapshot.Results.Count)),
parsedScenes);
var sourceChapterCount = (await sources.ListChaptersAsync(session.BookID)).Count;
var bookChapterTotal = Math.Max(1, Math.Max(sourceChapterCount, runSnapshots.Count));
var completedChapterRuns = runSnapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status));
var sessionSuccessfullyComplete = IsSuccessfulSession(session);
var allKnownRunsSuccessfullyComplete = runSnapshots.Count > 0 && runSnapshots.All(snapshot => IsSuccessfulTerminal(snapshot.Run.Status));
var isSuccessfullyComplete = sessionSuccessfullyComplete || allKnownRunsSuccessfullyComplete;
var isTerminal = isSuccessfullyComplete || IsTerminalSession(session) || runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status));
var calculatedVisualisationProgress = OverallProgress(parsedScenes, totalScenes, isSuccessfullyComplete);
var authoritativeImportProgress = BookProgress(runSnapshots, bookChapterTotal, isSuccessfullyComplete);
var displayedProgressReason = sessionSuccessfullyComplete
? "Successful terminal import session"
: allKnownRunsSuccessfullyComplete
? "All known chapter runs completed successfully"
: "Weighted chapter-run progress";
var overallProgressPercent = authoritativeImportProgress;
var overallElapsedMs = SessionElapsedMilliseconds(runSnapshots, isTerminal);
var overallElapsed = FormatDuration(overallElapsedMs);
var overallRemaining = isSuccessfullyComplete ? "Ready to review" : isTerminal ? "Ready to review" : EstimateSessionRemaining(overallElapsedMs, parsedScenes, totalScenes);
var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run);
var scenes = new List<StoryIntelligenceExperienceSceneState>();
var storyMemory = new StoryMemory();
var visibleSceneIds = replayMode
? allCompleted.Select(item => item.Result.SceneResultID).ToHashSet()
: VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0);
foreach (var snapshot in runSnapshots)
{
var runScenes = BuildSceneStates(
snapshot.Run,
snapshot.Parsed,
snapshot.Completed,
current,
overallCompletedScenes: parsedScenes,
overallTotalScenes: totalScenes,
overallChaptersAnalysed: completedChapterRuns,
overallChapterTotal: bookChapterTotal,
overallProgressPercent: overallProgressPercent,
overallStage: overallStage,
overallElapsed: overallElapsed,
overallRemaining: overallRemaining,
overallTerminal: isTerminal,
storyMemory: storyMemory,
includedSceneResultIds: visibleSceneIds)
.ToList();
scenes.AddRange(runScenes);
}
if (replayMode)
{
scenes = scenes
.OrderBy(scene => scene.ChapterNumber ?? decimal.MaxValue)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneResultId ?? int.MaxValue)
.Select((scene, index) => ReplayScene(scene, index, scenes.Count))
.ToList();
}
if (scenes.Count == 0)
{
scenes.Add(BuildEmptyScene(activeSnapshot.Run));
}
var appearancePreference = await storyMemoryRepository.GetAppearancePreferenceAsync(importSessionId);
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = replayMode ? "replay" : "real",
ImportSessionId = session.StoryIntelligenceBookPipelineID,
RunId = currentSnapshot.Run.StoryIntelligenceRunID,
BookId = session.BookID,
RunStatus = SessionStatus(session, runSnapshots),
GeneratedUtc = DateTime.UtcNow,
ChangeToken = SessionChangeToken(session, runSnapshots),
IsTerminal = replayMode || isTerminal,
SnapshotMessage = replayMode
? ReplaySnapshotMessage(session, runSnapshots, parsedScenes)
: SessionSnapshotMessage(session, runSnapshots),
Diagnostics = BuildSessionDiagnostics(
session,
runSnapshots,
currentSnapshot,
current,
parsedScenes,
totalPersistedResults,
totalScenes,
overallProgressPercent,
authoritativeImportProgress,
calculatedVisualisationProgress,
bookChapterTotal,
completedChapterRuns,
displayedProgressReason,
storyMemory,
replayMode,
appearancePreference),
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
Scenes = scenes
};
StabiliseCharacterIllustrations(model);
await ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model);
ApplyIllustrationDiagnostics(model);
StripLiveDiagnostics(model);
LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken);
LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds);
return model;
}
private static IEnumerable<StoryIntelligenceExperienceSceneState> BuildSceneStates(
StoryIntelligenceSavedRun run,
IReadOnlyList<ParsedSceneResult> allResults,
IReadOnlyList<ParsedSceneResult> completed,
ParsedSceneResult? current,
int continuousSceneNumberStart = 1,
int? overallCompletedScenes = null,
int? overallTotalScenes = null,
int? overallChaptersAnalysed = null,
int? overallChapterTotal = null,
int? overallProgressPercent = null,
string? overallStage = null,
string? overallElapsed = null,
string? overallRemaining = null,
bool continuousSceneNumbering = false,
bool overallTerminal = false,
StoryMemory? storyMemory = null,
IReadOnlySet<int>? includedSceneResultIds = null)
{
storyMemory ??= new StoryMemory();
var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count);
var completedScenes = run.CompletedScenes ?? completed.Count;
var displayIndex = 0;
foreach (var item in completed)
{
var scene = item.Scene!;
storyMemory.Update(scene, item);
var sourceSceneNumber = SceneNumber(item);
var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber;
if (includedSceneResultIds is not null && !includedSceneResultIds.Contains(item.Result.SceneResultID))
{
continue;
}
var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID;
var resolvedPovName = storyMemory.ResolvePointOfView(scene);
yield return new StoryIntelligenceExperienceSceneState
{
Id = $"scene-result-{item.Result.SceneResultID}",
IsCurrent = isCurrent,
ChapterRunId = run.StoryIntelligenceRunID,
ChapterNumber = scene.SceneReference?.ChapterNumber ?? run.ChapterNumber,
SceneResultId = item.Result.SceneResultID,
ChapterLabel = ChapterLabel(run, item),
SceneNumber = sceneNumber,
Title = SceneTitle(scene, item.Result, sceneNumber),
Summary = Trim(FirstConfigured(scene.Summary?.Short, scene.Summary?.Detailed, run.CurrentMessage, "Scene analysed."), 180),
Stage = overallStage ?? StageDisplay(run),
TimelineLabel = TimelineLabel(scene, item.Result),
TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42),
ProgressPercent = overallProgressPercent
?? (overallTotalScenes.HasValue
? OverallProgress(overallCompletedScenes ?? completedScenes, overallTotalScenes.Value, overallTerminal)
: Progress(run, completedScenes, totalScenes)),
ChaptersAnalysed = overallChaptersAnalysed ?? ChapterCount(completed),
ChapterTotal = Math.Max(1, overallChapterTotal ?? run.SourceChapterCount ?? ChapterCount(completed)),
ScenesAnalysed = overallCompletedScenes ?? completedScenes,
SceneTotal = Math.Max(1, overallTotalScenes ?? totalScenes),
ElapsedTime = overallElapsed ?? EstimateElapsed(run),
EstimatedRemaining = overallRemaining ?? EstimateRemaining(run, completedScenes, totalScenes),
PovCharacterId = CharacterId(resolvedPovName),
Location = BuildLocation(scene),
Characters = BuildCharacters(scene, storyMemory),
Assets = BuildAssets(scene),
Relationships = BuildRelationships(scene, storyMemory),
KnowledgeThreads = BuildKnowledge(scene),
Observations = BuildObservations(scene, resolvedPovName),
LatestDiscoveries = isCurrent ? BuildDiscoveries(scene).ToList() : [],
ActivityFeed = isCurrent ? BuildActivity(run, sceneNumber).ToList() : []
};
}
}
private static StoryIntelligenceExperienceSceneState ReplayScene(StoryIntelligenceExperienceSceneState scene, int index, int total)
{
var replayProgress = total <= 1 ? 100 : Math.Clamp((int)Math.Round((index + 1) * 100m / total), 1, 100);
return new()
{
Id = scene.Id,
IsCurrent = index == 0,
ChapterRunId = scene.ChapterRunId,
ChapterNumber = scene.ChapterNumber,
SceneResultId = scene.SceneResultId,
ReplayIndex = index,
GlobalSceneIndex = index + 1,
ChapterLabel = scene.ChapterLabel,
SceneNumber = scene.SceneNumber,
Title = scene.Title,
Summary = scene.Summary,
Stage = $"Replay saved analysis - {scene.ChapterLabel} Scene {scene.SceneNumber:N0}",
TimelineLabel = scene.TimelineLabel,
TimelineEvent = scene.TimelineEvent,
ProgressPercent = replayProgress,
ChaptersAnalysed = scene.ChaptersAnalysed,
ChapterTotal = scene.ChapterTotal,
ScenesAnalysed = index + 1,
SceneTotal = total,
ElapsedTime = $"Scene {index + 1:N0} of {total:N0}",
EstimatedRemaining = index >= total - 1 ? "Replay complete" : "Replay mode",
PovCharacterId = scene.PovCharacterId,
Location = scene.Location,
Characters = scene.Characters,
Assets = scene.Assets,
Relationships = scene.Relationships,
KnowledgeThreads = scene.KnowledgeThreads,
Observations = scene.Observations,
LatestDiscoveries = scene.LatestDiscoveries,
ActivityFeed = scene.ActivityFeed
};
}
private static StoryIntelligenceExperienceSceneState BuildEmptyScene(StoryIntelligenceSavedRun run)
=> new()
{
Id = $"run-{run.StoryIntelligenceRunID}-waiting",
ChapterLabel = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter",
SceneNumber = 1,
Title = run.Status is StoryIntelligenceRunStatuses.Failed ? "Analysis failed" : "Waiting for analysed scenes",
Summary = FirstConfigured(run.ErrorMessage, run.CurrentMessage, "No scene analysis results have been persisted yet."),
Stage = StageDisplay(run),
TimelineLabel = "Pending",
TimelineEvent = "No scene snapshot yet",
ProgressPercent = Progress(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0),
ChaptersAnalysed = 0,
ChapterTotal = Math.Max(1, run.SourceChapterCount ?? 1),
ScenesAnalysed = run.CompletedScenes ?? 0,
SceneTotal = Math.Max(1, run.TotalDetectedScenes ?? 1),
ElapsedTime = EstimateElapsed(run),
EstimatedRemaining = EstimateRemaining(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0),
Location = PendingLocation(),
LatestDiscoveries = run.ErrorMessage is null ? [] : [$"Run failed: {Trim(run.ErrorMessage, 90)}"],
ActivityFeed = BuildActivity(run, null).ToList()
};
private static ParsedSceneResult? PickCurrentScene(StoryIntelligenceSavedRun run, IReadOnlyList<ParsedSceneResult> completed)
{
if (completed.Count == 0)
{
return null;
}
return completed
.OrderBy(item => item.Result.CreatedUtc)
.ThenBy(item => item.Result.SceneResultID)
.Last();
}
private static ParsedSceneResult? PickAuthoritativeCurrentScene(IReadOnlyList<RunSnapshot> snapshots)
=> snapshots
.SelectMany(snapshot => snapshot.Completed)
.OrderBy(item => item.Result.CreatedUtc)
.ThenBy(item => item.Result.SceneResultID)
.LastOrDefault();
private static RunSnapshot? OwnerOf(IReadOnlyList<RunSnapshot> snapshots, ParsedSceneResult? scene)
=> scene is null
? null
: snapshots.FirstOrDefault(snapshot => snapshot.Run.StoryIntelligenceRunID == scene.Result.StoryIntelligenceRunID);
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene, StoryMemory? storyMemory = null)
{
var narratorResolution = storyMemory?.ResolvePointOfView(scene) ?? ResolveNarratorName(scene);
var povName = Clean(narratorResolution ?? scene.PointOfView?.CharacterName);
var states = new List<StoryIntelligenceExperienceCharacterState>();
var reservedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in (scene.Characters ?? [])
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
.GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase)
.Take(5))
{
if (!string.IsNullOrWhiteSpace(narratorResolution) && IsGenericNarratorName(group.Key))
{
continue;
}
var character = group.OrderByDescending(item => item.Confidence ?? 0).First();
var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase);
var libraryCode = CharacterLibraryCode(group.Key, character, scene, reservedCodes);
reservedCodes.Add(libraryCode);
states.Add(new StoryIntelligenceExperienceCharacterState
{
Id = CharacterId(group.Key),
LibraryCode = libraryCode,
Name = group.Key,
Role = isPov ? "POV character" : NonPovRole(character),
Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60),
Weight = isPov ? 100 : CharacterWeight(character),
ImagePath = CharacterFallback(group.Key)
});
}
if (!string.IsNullOrWhiteSpace(povName) && states.All(character => !string.Equals(character.Name, povName, StringComparison.OrdinalIgnoreCase)))
{
var libraryCode = CharacterLibraryCode(povName, null, scene, reservedCodes);
states.Insert(0, new StoryIntelligenceExperienceCharacterState
{
Id = CharacterId(povName),
LibraryCode = libraryCode,
Name = povName,
Role = "POV character",
Relevance = "Point of view",
Weight = 100,
ImagePath = CharacterFallback(povName)
});
}
return states
.OrderByDescending(character => character.Weight)
.GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToList();
}
private static string NonPovRole(SceneIntelligenceCharacter character)
{
var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
return NonPovRole(role, character.MentionedOnly == true);
}
private static string NonPovRole(string? role, bool referenced = false)
{
role = FirstConfigured(role, referenced ? "Referenced" : "Scene character");
return role.Contains("pov", StringComparison.OrdinalIgnoreCase)
|| role.Contains("point of view", StringComparison.OrdinalIgnoreCase)
|| role.Contains("narrator", StringComparison.OrdinalIgnoreCase)
? referenced ? "Referenced" : "Scene character"
: role;
}
private static string? ResolveNarratorName(SceneIntelligenceScene scene)
{
var povName = Clean(scene.PointOfView?.CharacterName);
if (!IsGenericNarratorName(povName))
{
return null;
}
var evidence = string.Join(' ', new[]
{
scene.PointOfView?.Evidence,
scene.Summary?.Short,
scene.Summary?.Detailed,
scene.ScenePurpose?.ObservedFunction
}.Where(value => !string.IsNullOrWhiteSpace(value)));
var namedCharacters = (scene.Characters ?? [])
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
.Select(character => Clean(character.Name))
.Where(name => !IsGenericNarratorName(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var name in namedCharacters)
{
if (ContainsIdentityEvidence(evidence, name))
{
return name;
}
}
return namedCharacters.Count == 1 ? namedCharacters[0] : null;
}
private static bool ContainsIdentityEvidence(string evidence, string name)
{
if (string.IsNullOrWhiteSpace(evidence) || string.IsNullOrWhiteSpace(name))
{
return false;
}
var lowerEvidence = evidence.ToLowerInvariant();
var lowerName = name.ToLowerInvariant();
return lowerEvidence.Contains($"narrated by {lowerName}", StringComparison.Ordinal)
|| lowerEvidence.Contains($"narrator is {lowerName}", StringComparison.Ordinal)
|| lowerEvidence.Contains($"i am {lowerName}", StringComparison.Ordinal)
|| lowerEvidence.Contains($"i'm {lowerName}", StringComparison.Ordinal)
|| lowerEvidence.Contains($"my name is {lowerName}", StringComparison.Ordinal);
}
private static bool IsGenericNarratorName(string? name)
{
var value = Clean(name).ToLowerInvariant();
return value is "narrator" or "i" or "me" or "my" or "myself" or "first person narrator" or "unnamed narrator";
}
private static StoryIntelligenceExperienceLocationState BuildLocation(SceneIntelligenceScene scene)
{
var location = (scene.Locations ?? [])
.Where(item => !string.IsNullOrWhiteSpace(item.Name) && item.MentionedOnly != true)
.OrderByDescending(item => item.PresentInScene == true)
.ThenByDescending(item => Specificity(item.Name))
.ThenByDescending(item => item.Confidence ?? 0)
.FirstOrDefault();
var name = Clean(FirstConfigured(location?.Name, scene.Setting?.LocationName));
if (string.IsNullOrWhiteSpace(name))
{
return PendingLocation();
}
return new StoryIntelligenceExperienceLocationState
{
Id = $"location-result-{StableKey(name)}",
LibraryCode = LocationLibraryCode(name, location, scene.Setting),
Name = name,
Label = Trim(FirstConfigured(location?.ParentLocationHint, location?.LocationType, location?.GenericRoomType, scene.Setting?.ParentLocationHint, "Active scene location"), 44),
ImagePath = LocationFallback(name)
};
}
private static IReadOnlyList<StoryIntelligenceExperienceAssetState> BuildAssets(SceneIntelligenceScene scene)
=> (scene.Assets ?? [])
.Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && asset.MentionedOnly != true && !IsLowValueAsset(asset))
.GroupBy(asset => Clean(asset.Name), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var asset = group.OrderByDescending(item => item.Confidence ?? 0).First();
return new StoryIntelligenceExperienceAssetState
{
Id = $"asset-result-{StableKey(group.Key)}",
LibraryCode = AssetLibraryCode(group.Key, asset),
Name = group.Key,
Label = Trim(FirstConfigured(asset.Status, asset.AssetType, asset.Notes, "Story asset"), 32),
Weight = AssetWeight(asset),
ImagePath = AssetFallback(group.Key)
};
})
.OrderByDescending(asset => asset.Weight)
.Take(5)
.ToList();
private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> BuildRelationships(SceneIntelligenceScene scene, StoryMemory? storyMemory = null)
{
var current = (scene.Relationships ?? [])
.Where(item => !string.IsNullOrWhiteSpace(item.CharacterA) && !string.IsNullOrWhiteSpace(item.CharacterB))
.Select(item => new StoryIntelligenceExperienceRelationshipState
{
Id = $"relationship-result-{StableKey(item.CharacterA!)}-{StableKey(item.CharacterB!)}-{StableKey(item.RelationshipSignal)}",
SourceId = CharacterId(item.CharacterA),
TargetId = CharacterId(item.CharacterB),
Label = Trim(FirstConfigured(item.RelationshipSignal, "Relationship signal"), 44),
State = Trim(FirstConfigured(item.Evidence, item.RelationshipSignal, "Emerging"), 58),
Weight = ConfidenceWeight(item.Confidence, 70)
})
.OrderByDescending(item => item.Weight)
.ToList();
var seen = new HashSet<string>(current.Select(item => $"{item.SourceId}|{item.TargetId}"), StringComparer.OrdinalIgnoreCase);
var inferredKinship = InferredKinshipRelationships(scene, seen);
var memoryRelationships = (storyMemory?.Relationships ?? [])
.Where(item => seen.Add($"{item.SourceId}|{item.TargetId}"))
.Select(item => new StoryIntelligenceExperienceRelationshipState
{
Id = item.Id,
SourceId = item.SourceId,
TargetId = item.TargetId,
Label = item.Label,
State = item.State,
Weight = item.Id.StartsWith("memory-inferred-relationship-", StringComparison.Ordinal)
? item.Weight
: Math.Max(44, item.Weight - 16)
});
return current.Concat(inferredKinship).Concat(memoryRelationships)
.OrderByDescending(item => item.Weight)
.Take(4)
.ToList();
}
private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> InferredKinshipRelationships(SceneIntelligenceScene scene, HashSet<string> seen)
{
var characters = (scene.Characters ?? [])
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
.Select(character => new
{
Name = Clean(character.Name),
Text = $"{character.Name} {character.RoleInScene} {character.Notes} {string.Join(' ', character.Actions ?? [])}".ToLowerInvariant()
})
.DistinctBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var beth = characters.FirstOrDefault(character => string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase));
if (beth is null)
{
return [];
}
var relationships = new List<StoryIntelligenceExperienceRelationshipState>();
foreach (var character in characters.Where(character => !string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase)))
{
var label = character.Text.Contains("sister", StringComparison.OrdinalIgnoreCase) || string.Equals(character.Name, "Grace", StringComparison.OrdinalIgnoreCase)
? "Sister"
: character.Text.Contains("aunt", StringComparison.OrdinalIgnoreCase) || character.Name.Contains("Aunt", StringComparison.OrdinalIgnoreCase)
? "Aunt/Niece"
: character.Text.Contains("mother", StringComparison.OrdinalIgnoreCase) || character.Text.Contains(" mum", StringComparison.OrdinalIgnoreCase)
? "Mother/Daughter"
: string.Empty;
if (string.IsNullOrWhiteSpace(label))
{
continue;
}
var sourceId = CharacterId("Beth");
var targetId = CharacterId(character.Name);
if (!seen.Add($"{sourceId}|{targetId}"))
{
continue;
}
relationships.Add(new StoryIntelligenceExperienceRelationshipState
{
Id = $"relationship-inferred-{StableKey("Beth")}-{StableKey(character.Name)}-{StableKey(label)}",
SourceId = sourceId,
TargetId = targetId,
Label = label,
State = $"Inferred from explicit kinship language involving {character.Name}.",
Weight = 88
});
}
return relationships;
}
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> BuildKnowledge(SceneIntelligenceScene scene)
{
var questions = (scene.QuestionsRaised ?? []).Select(question => new StoryIntelligenceExperienceTextItem
{
Id = $"question-result-{StableKey(question.Question)}",
Title = Trim(FirstConfigured(question.Question, "Question raised"), 70),
Detail = Trim(FirstConfigured(question.Evidence, question.Scope, "Open question"), 120),
Tone = "question"
});
var knowledge = (scene.KnowledgeChanges ?? []).Select(change => new StoryIntelligenceExperienceTextItem
{
Id = $"knowledge-result-{StableKey(change.RecipientCharacter)}-{StableKey(change.KnowledgeItem)}",
Title = Trim(FirstConfigured(change.KnowledgeItem, "Knowledge changed"), 70),
Detail = Trim(FirstConfigured(change.Evidence, change.ChangeType, change.SourceType, "Knowledge update"), 120),
Tone = "secret"
});
return questions.Concat(knowledge).Take(4).ToList();
}
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> BuildObservations(SceneIntelligenceScene scene, string? povName)
=> (scene.Observations ?? []).Select(observation => new StoryIntelligenceExperienceTextItem
{
Id = $"observation-result-{StableKey(observation.ObservationType)}-{StableKey(observation.SubjectName)}-{StableKey(observation.Predicate)}",
Title = Trim(NamedPovText(FirstConfigured(observation.Description, observation.Predicate, observation.ObservationType, "Story observation"), povName), 74),
Detail = Trim(NamedPovText(FirstConfigured(observation.Evidence, $"{observation.SubjectName} {observation.Predicate} {observation.ObjectName}".Trim(), "Observed in current scene"), povName), 128),
Tone = "insight"
})
.Take(4)
.ToList();
private static string NamedPovText(string text, string? povName)
{
var name = Clean(povName);
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name))
{
return text;
}
return text
.Replace("The narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase)
.Replace("the narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase)
.Replace("The narrator", name, StringComparison.OrdinalIgnoreCase)
.Replace("the narrator", name, StringComparison.OrdinalIgnoreCase)
.Replace("Narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase)
.Replace("narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase)
.Replace("Narrator", name, StringComparison.OrdinalIgnoreCase)
.Replace("narrator", name, StringComparison.OrdinalIgnoreCase);
}
private static IEnumerable<string> BuildDiscoveries(SceneIntelligenceScene scene)
{
foreach (var character in (scene.Characters ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name)).Take(2))
{
yield return $"Character found: {Clean(character.Name)}";
}
var location = FirstConfigured(scene.Locations?.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Name))?.Name, scene.Setting?.LocationName);
if (!string.IsNullOrWhiteSpace(location))
{
yield return $"Location found: {Clean(location)}";
}
foreach (var asset in (scene.Assets ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name) && !IsLowValueAsset(item)).Take(2))
{
yield return $"Asset found: {Clean(asset.Name)}";
}
}
private static IEnumerable<string> BuildActivity(StoryIntelligenceSavedRun run, int? sceneNumber)
{
if (!string.IsNullOrWhiteSpace(run.CurrentMessage))
{
yield return run.CurrentMessage!;
}
if (sceneNumber.HasValue)
{
yield return $"Scene {sceneNumber:N0} snapshot loaded";
}
yield return $"Run status: {run.Status}";
}
private ParsedSceneResult ReadSceneResult(int runId, StoryIntelligenceSavedSceneResult result)
{
var scene = TryReadScene(result, out var error);
if (scene is null)
{
logger.LogWarning(
"Story Intelligence visualisation skipped unparseable scene result {SceneResultId} for run {RunId}: {ParseError}",
result.SceneResultID,
runId,
error ?? "Parsed scene was empty or missing schema version.");
}
return new ParsedSceneResult(result, scene, error);
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
{
safeError = null;
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
safeError = "Parsed JSON was empty.";
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 ex)
{
safeError = ex.Message;
}
safeError ??= "Parsed JSON did not contain a scene payload.";
return null;
}
private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> results)
{
var resultToken = string.Join(';', results
.OrderBy(result => result.SceneResultID)
.Select(result => string.Join('|',
result.SceneResultID,
result.ChapterID,
result.SceneID,
result.TemporarySceneNumber,
result.ValidationErrorsCount,
result.ValidationWarningsCount,
result.InputTokens,
result.OutputTokens,
result.TotalTokens,
result.DurationMs,
result.CreatedUtc.ToUniversalTime().Ticks,
StableContentHash(result.ParsedJson))));
var raw = string.Join('|',
run.StoryIntelligenceRunID,
run.Status,
run.CurrentStage,
run.CurrentMessage,
run.CompletedScenes,
run.FailedScenes,
run.TotalDetectedScenes,
run.CompletedUtc?.ToUniversalTime().Ticks,
run.UpdatedUtc.ToUniversalTime().Ticks,
run.TotalInputTokens,
run.TotalOutputTokens,
run.TotalTokens,
run.TotalDurationMs,
run.ErrorMessage,
run.ErrorDetail is null ? string.Empty : StableContentHash(run.ErrorDetail),
results.Count,
resultToken);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
}
private static string SessionChangeToken(StoryIntelligenceBookPipelineState session, IReadOnlyList<RunSnapshot> snapshots)
{
var raw = string.Join('|',
session.StoryIntelligenceBookPipelineID,
session.Status,
session.CurrentStage,
session.LastCompletedStage,
session.CurrentReviewStage,
session.UpdatedUtc.ToUniversalTime().Ticks,
string.Join("~", snapshots.Select(snapshot => ChangeToken(snapshot.Run, snapshot.Results))));
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
}
private static RunSnapshot? PickActiveRunSnapshot(IReadOnlyList<RunSnapshot> snapshots)
=> snapshots
.Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Running, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(snapshot => snapshot.Run.UpdatedUtc)
.ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID)
.FirstOrDefault()
?? snapshots
.Where(snapshot => !IsTerminal(snapshot.Run.Status))
.OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID)
.FirstOrDefault()
?? snapshots
.Where(snapshot => snapshot.Completed.Count > 0)
.OrderByDescending(snapshot => snapshot.Run.UpdatedUtc)
.ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID)
.FirstOrDefault()
?? snapshots.LastOrDefault();
private static string OverallStage(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedRun activeRun)
{
if (!IsTerminal(activeRun.Status))
{
return FirstConfigured(activeRun.CurrentStage, activeRun.Status);
}
return session.CurrentStage switch
{
StoryIntelligencePipelineStages.Complete => "Ready for Review",
StoryIntelligencePipelineStages.Chapters => "Reading Chapters",
_ => session.CurrentStage
};
}
private static string EstimateSessionRemaining(long elapsedMs, int completedScenes, int totalScenes)
{
if (totalScenes <= 0 || completedScenes <= 0)
{
return "Calculating";
}
if (completedScenes >= totalScenes)
{
return "Ready to review";
}
var averageMs = elapsedMs / Math.Max(1, completedScenes);
return FormatDuration(averageMs * Math.Max(0, totalScenes - completedScenes));
}
private static string SessionStatus(StoryIntelligenceBookPipelineState session, IReadOnlyList<RunSnapshot> snapshots)
{
if (IsSuccessfulSession(session))
{
return StoryIntelligenceRunStatuses.Completed;
}
if (snapshots.Any(snapshot => !IsTerminal(snapshot.Run.Status)))
{
return StoryIntelligenceRunStatuses.Running;
}
return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
? StoryIntelligenceRunStatuses.Completed
: StoryIntelligenceRunStatuses.CompletedWithWarnings;
}
private static string SessionSnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList<RunSnapshot> snapshots)
{
if (IsSuccessfulSession(session))
{
return "Book analysis complete and ready for review.";
}
var active = snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status));
if (active is not null)
{
return BackendStageDisplay(active.Run);
}
return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
? "Book analysis complete and ready for review."
: "All known chapter runs are complete. Awaiting the next book import stage.";
}
private sealed class StoryMemory
{
private readonly Dictionary<string, MemoryCharacter> characters = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, StoryIntelligenceExperienceRelationshipState> relationships = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, PovResolution> chapterPov = new(StringComparer.OrdinalIgnoreCase);
private string? narratorResolvedName;
private string? bookNarratorName;
private string? previousPovName;
private string? previousChapterKey;
public IReadOnlyCollection<MemoryCharacter> Characters => characters.Values;
public IReadOnlyCollection<StoryIntelligenceExperienceRelationshipState> Relationships => relationships.Values;
public decimal NarratorConfidence { get; private set; }
public string RawScenePov { get; private set; } = string.Empty;
public string InheritedChapterPov { get; private set; } = string.Empty;
public string ResolvedPovIdentity { get; private set; } = string.Empty;
public string PovResolutionReason { get; private set; } = string.Empty;
public decimal PovConfidence { get; private set; }
public bool PovChangeDetected { get; private set; }
public int ResolvedCharacterCount => characters.Values.Count(character => character.IsResolved);
public int UnresolvedCharacterCount => characters.Values.Count(character => !character.IsResolved);
private string CurrentChapterKey { get; set; } = string.Empty;
public void Update(SceneIntelligenceScene scene, ParsedSceneResult result)
{
CurrentChapterKey = ChapterMemoryKey(scene, result);
RawScenePov = Clean(scene.PointOfView?.CharacterName);
InheritedChapterPov = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? string.Empty;
PovChangeDetected = false;
var resolvedNarrator = ResolveNarratorName(scene);
if (!string.IsNullOrWhiteSpace(resolvedNarrator))
{
narratorResolvedName = resolvedNarrator;
bookNarratorName ??= resolvedNarrator;
NarratorConfidence = Math.Max(NarratorConfidence, 0.82m);
SetChapterPov(CurrentChapterKey, resolvedNarrator, 0.86m, "explicit narrator identity in current scene", result);
}
else if (IsGenericNarratorName(scene.PointOfView?.CharacterName))
{
NarratorConfidence = Math.Max(NarratorConfidence, 0.35m);
}
else if (!string.IsNullOrWhiteSpace(RawScenePov))
{
var existing = chapterPov.GetValueOrDefault(CurrentChapterKey);
PovChangeDetected = existing is not null && !string.Equals(existing.Name, RawScenePov, StringComparison.OrdinalIgnoreCase);
bookNarratorName ??= RawScenePov;
SetChapterPov(CurrentChapterKey, RawScenePov, 0.92m, PovChangeDetected ? "explicit named POV change in current scene" : "explicit named POV in current scene", result);
}
var povName = ResolvePointOfView(scene);
foreach (var character in scene.Characters ?? [])
{
var name = Clean(character.Name);
if (string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name))
{
var inheritedPov = ResolvePointOfView(scene);
if (!string.IsNullOrWhiteSpace(inheritedPov))
{
name = inheritedPov;
}
else
{
name = "Narrator";
}
}
var memory = GetOrAddCharacter(name);
memory.Weight = Math.Max(memory.Weight, CharacterWeight(character));
memory.Role = FirstConfigured(memory.Role, character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
memory.Relevance = Trim(FirstConfigured(character.Notes, memory.Relevance, string.Join(", ", character.Actions ?? []), "Remembered from earlier scenes"), 60);
var evidence = $"{name} {character.RoleInScene} {character.Notes} {string.Join(" ", character.Actions ?? [])}";
ApplyInference(memory, evidence);
InferSemanticRelationship(scene, name, evidence, povName);
}
InferSummaryRelationships(scene, povName);
if (!string.IsNullOrWhiteSpace(povName))
{
var pov = GetOrAddCharacter(povName);
pov.PovCount += 1;
pov.Weight = Math.Max(pov.Weight, 100);
pov.Role = "POV character";
pov.Relevance = pov.PovCount > 1 ? "Recurring point of view" : "Point of view";
}
foreach (var relationship in scene.Relationships ?? [])
{
var a = ResolveCharacterName(relationship.CharacterA);
var b = ResolveCharacterName(relationship.CharacterB);
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
{
continue;
}
ApplyInference(GetOrAddCharacter(a), $"{relationship.RelationshipSignal} {relationship.Evidence}");
ApplyInference(GetOrAddCharacter(b), $"{relationship.RelationshipSignal} {relationship.Evidence}");
var key = $"{CharacterId(a)}|{CharacterId(b)}";
relationships[key] = new StoryIntelligenceExperienceRelationshipState
{
Id = $"memory-relationship-{StableKey(a)}-{StableKey(b)}",
SourceId = CharacterId(a),
TargetId = CharacterId(b),
Label = Trim(FirstConfigured(relationship.RelationshipSignal, "Relationship remembered"), 44),
State = Trim(FirstConfigured(relationship.Evidence, relationship.RelationshipSignal, "Carried forward from earlier scenes"), 58),
Weight = ConfidenceWeight(relationship.Confidence, 64)
};
}
previousPovName = ResolvePointOfView(scene);
previousChapterKey = CurrentChapterKey;
}
public string? ResolvePointOfView(SceneIntelligenceScene scene)
{
var raw = Clean(scene.PointOfView?.CharacterName);
if (IsGenericNarratorName(raw))
{
var resolved = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null);
ResolvedPovIdentity = resolved ?? raw;
PovResolutionReason = resolved is null ? "unresolved narrator" : "inherited same-chapter POV";
PovConfidence = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Confidence ?? (resolved is null ? 0.35m : 0.74m);
return resolved ?? raw;
}
var fallback = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null);
ResolvedPovIdentity = string.IsNullOrWhiteSpace(raw) ? fallback ?? string.Empty : raw;
PovResolutionReason = string.IsNullOrWhiteSpace(raw) ? "inherited previous POV" : "explicit named POV";
PovConfidence = string.IsNullOrWhiteSpace(raw) ? 0.72m : 0.92m;
return string.IsNullOrWhiteSpace(raw) ? fallback : raw;
}
private void SetChapterPov(string chapterKey, string name, decimal confidence, string reason, ParsedSceneResult result)
{
chapterPov[chapterKey] = new PovResolution(name, confidence, reason, result.Result.SceneResultID);
InheritedChapterPov = name;
ResolvedPovIdentity = name;
PovResolutionReason = reason;
PovConfidence = confidence;
}
private static string ChapterMemoryKey(SceneIntelligenceScene scene, ParsedSceneResult result)
=> (scene.SceneReference?.ChapterNumber ?? SceneSortChapter(result)).ToString("0.##");
private string ResolveCharacterName(string? raw)
{
var name = Clean(raw);
if (!IsGenericNarratorName(name))
{
return name;
}
return chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null)
?? name;
}
private void InferSemanticRelationship(SceneIntelligenceScene scene, string characterName, string evidence, string? povName)
{
if (string.Equals(characterName, "Grace", StringComparison.OrdinalIgnoreCase))
{
AddInferredRelationship("Grace", "Beth", "sister", "Inferred from remembered Alpha Flame family context", 96);
}
var term = RelationshipTerm(characterName, evidence);
if (term is null)
{
return;
}
var target = RelationshipTarget(scene, characterName, evidence, povName);
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, characterName, StringComparison.OrdinalIgnoreCase))
{
return;
}
AddInferredRelationship(characterName, target, term.Value.Label, term.Value.State, term.Value.Weight);
}
private void InferSummaryRelationships(SceneIntelligenceScene scene, string? povName)
{
var text = $" {scene.Summary?.Short} {scene.Summary?.Detailed} {scene.ScenePurpose?.ObservedFunction} ".ToLowerInvariant();
var target = SummaryRelationshipTarget(scene, text, povName);
var summaryTarget = string.IsNullOrWhiteSpace(target) && text.Contains("beth", StringComparison.OrdinalIgnoreCase) ? "Beth" : target;
if (text.Contains("sister grace", StringComparison.OrdinalIgnoreCase)
|| text.Contains("sister named grace", StringComparison.OrdinalIgnoreCase)
|| text.Contains("sister, grace", StringComparison.OrdinalIgnoreCase)
|| text.Contains("sister Grace", StringComparison.OrdinalIgnoreCase)
|| (text.Contains("sister", StringComparison.OrdinalIgnoreCase) && text.Contains("grace", StringComparison.OrdinalIgnoreCase)))
{
AddInferredRelationship("Grace", FirstConfigured(summaryTarget, "Beth"), "sister", "Inferred from scene summary", 86);
}
if (text.Contains("aunt (elen)", StringComparison.OrdinalIgnoreCase)
|| text.Contains("aunt elen", StringComparison.OrdinalIgnoreCase)
|| text.Contains("aunt, elen", StringComparison.OrdinalIgnoreCase)
|| text.Contains("elen", StringComparison.OrdinalIgnoreCase))
{
var elen = GetOrAddCharacter("Elen");
elen.Weight = Math.Max(elen.Weight, 82);
elen.Role = "Inferred aunt";
elen.Relevance = "Named in scene summary";
elen.Presentation = "Feminine";
elen.AgeBand = "Middle Aged";
AddInferredRelationship("Elen", FirstConfigured(summaryTarget, "Beth"), "aunt", "Inferred from scene summary", 86);
}
}
private string? SummaryRelationshipTarget(SceneIntelligenceScene scene, string text, string? povName)
{
var candidates = (scene.Characters ?? [])
.Select(character => Clean(character.Name))
.Concat(characters.Values.Select(character => character.Name))
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsGenericNarratorName(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (text.Contains("beth", StringComparison.OrdinalIgnoreCase))
{
var beth = candidates.FirstOrDefault(candidate => string.Equals(candidate, "Beth", StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(beth))
{
return beth;
}
}
return !string.IsNullOrWhiteSpace(povName) ? povName : candidates.FirstOrDefault();
}
private void AddInferredRelationship(string sourceName, string targetName, string label, string state, int weight)
{
var source = ResolveCharacterName(sourceName);
var target = ResolveCharacterName(targetName);
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target) || string.Equals(source, target, StringComparison.OrdinalIgnoreCase))
{
return;
}
GetOrAddCharacter(source);
GetOrAddCharacter(target);
var key = $"{CharacterId(source)}|{CharacterId(target)}";
relationships.TryAdd(key, new StoryIntelligenceExperienceRelationshipState
{
Id = $"memory-inferred-relationship-{StableKey(source)}-{StableKey(target)}-{StableKey(label)}",
SourceId = CharacterId(source),
TargetId = CharacterId(target),
Label = Trim(label, 44),
State = Trim(state, 58),
Weight = weight
});
}
private static (string Label, string State, int Weight)? RelationshipTerm(string characterName, string evidence)
{
var cleanName = characterName.Trim().ToLowerInvariant();
if (cleanName is "mother" or "mum" or "father" or "dad" or "sister" or "brother" or "aunt" or "uncle")
{
return null;
}
var nameText = $" {characterName} ".ToLowerInvariant();
var evidenceText = $" {evidence} ".ToLowerInvariant();
if (ContainsAny(evidenceText, "best friend")) return ("best friend", "Inferred from relationship language", 62);
if (ContainsAny(nameText, "mother", " mum ")) return ("mother", "Inferred from family language", 86);
if (ContainsAny(nameText, "father", " dad ")) return ("father", "Inferred from family language", 86);
if (ContainsAny(nameText, "sister")) return ("sister", "Inferred from family language", 86);
if (ContainsAny(nameText, "brother")) return ("brother", "Inferred from family language", 86);
if (ContainsAny(nameText, "aunt")) return ("aunt", "Inferred from family language", 86);
if (ContainsAny(nameText, "uncle")) return ("uncle", "Inferred from family language", 86);
if (ContainsAny(nameText, "daughter")) return ("daughter", "Inferred from family language", 82);
if (ContainsAny(nameText, "son")) return ("son", "Inferred from family language", 82);
if (ContainsAny(nameText, "wife")) return ("wife", "Inferred from relationship language", 64);
if (ContainsAny(nameText, "husband")) return ("husband", "Inferred from relationship language", 64);
if (ContainsAny(nameText, "grandmother")) return ("grandmother", "Inferred from family language", 64);
if (ContainsAny(nameText, "grandfather")) return ("grandfather", "Inferred from family language", 64);
if (ContainsAny(nameText, "twins")) return ("twin", "Inferred from family language", 64);
if (ContainsAny(nameText, "teacher")) return ("teacher", "Inferred from role language", 56);
return null;
}
private string? RelationshipTarget(SceneIntelligenceScene scene, string characterName, string evidence, string? povName)
{
var text = $" {evidence} ".ToLowerInvariant();
var candidates = (scene.Characters ?? [])
.Select(character => Clean(character.Name))
.Concat(characters.Values.Select(character => character.Name))
.Where(name => !string.IsNullOrWhiteSpace(name)
&& !IsGenericNarratorName(name)
&& !string.Equals(name, characterName, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var candidate in candidates)
{
var lower = candidate.ToLowerInvariant();
if (text.Contains($"{lower}'s", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{lower}' ", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"of {lower}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"to {lower}", StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
if (!string.IsNullOrWhiteSpace(povName)
&& !string.Equals(povName, characterName, StringComparison.OrdinalIgnoreCase)
&& candidates.Any(candidate => string.Equals(candidate, povName, StringComparison.OrdinalIgnoreCase)))
{
return povName;
}
return candidates.Count == 1 ? candidates[0] : null;
}
private MemoryCharacter GetOrAddCharacter(string name)
{
var key = CharacterId(name);
if (!characters.TryGetValue(key, out var character))
{
character = new MemoryCharacter(name);
characters[key] = character;
}
return character;
}
private static void ApplyInference(MemoryCharacter character, string evidence)
{
var text = evidence.ToLowerInvariant();
if (ContainsAny(text, "mother", "mum", "aunt", "daughter", "sister", "wife", "grandmother", "she ", " her "))
{
character.Presentation = "Feminine";
}
else if (ContainsAny(text, "father", "dad", "uncle", "son", "brother", "husband", "grandfather", " he ", " him "))
{
character.Presentation = "Masculine";
}
else
{
character.Presentation ??= NamePresentation(character.Name);
}
if (ContainsAny(text, "grandfather", "grandmother", "elderly"))
{
character.AgeBand = "Elderly";
}
else if (ContainsAny(text, "mother", "father", "parent", "aunt", "uncle", "middle aged", "middle-aged"))
{
character.AgeBand = "Middle Aged";
}
else if (ContainsAny(text, "teenage", "teenager", "fifteen", "sixteen", "seventeen"))
{
character.AgeBand = "Older Teen";
}
}
private static string? NamePresentation(string name)
{
var key = name.ToLowerInvariant();
if (key is "alison" or "annie" or "anna" or "anne" or "beth" or "chrissy" or "grace" or "helen" or "elen" or "judy" or "katherine" or "maggie" or "mary" or "rebecca" or "rosie" or "sharon" or "sophie")
{
return "Feminine";
}
if (key is "colin" or "david" or "george" or "graham" or "greg" or "john" or "kevin" or "phil" or "roger" or "rick" or "rob" or "simon")
{
return "Masculine";
}
return null;
}
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
}
private sealed class MemoryCharacter(string name)
{
public string Name { get; } = name;
public string Role { get; set; } = "Remembered character";
public string Relevance { get; set; } = "Known from earlier scenes";
public int Weight { get; set; } = 48;
public int PovCount { get; set; }
public string? Presentation { get; set; }
public string? AgeBand { get; set; }
public bool IsResolved => !IsGenericNarratorName(Name);
}
private sealed record PovResolution(string Name, decimal Confidence, string Reason, int LastConfirmedSceneResultId);
private static string CurrentSceneText(RunSnapshot snapshot)
{
if (snapshot.Run.TotalDetectedScenes.HasValue)
{
var scene = Math.Clamp(snapshot.Run.CompletedScenes ?? snapshot.Completed.Count, 0, snapshot.Run.TotalDetectedScenes.Value);
return $"Scene {scene:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}";
}
return snapshot.Current is null ? "Waiting" : $"Scene {SceneSortNumber(snapshot.Current):0.##}";
}
private static string BackendStageDisplay(StoryIntelligenceSavedRun run)
{
var stage = FirstConfigured(run.CurrentStage, run.Status);
var sceneText = run.TotalDetectedScenes.HasValue
? $"Scene {Math.Clamp((run.CompletedScenes ?? 0) + (IsTerminal(run.Status) ? 0 : 1), 1, Math.Max(1, run.TotalDetectedScenes.Value)):N0}"
: "current scene";
var chapterText = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##} " : string.Empty;
return stage switch
{
StoryIntelligenceFailureStages.SceneIntelligence => $"Analysing {chapterText}{sceneText}",
StoryIntelligencePipelineStages.CharacterImport or StoryIntelligencePipelineStages.CharacterReview => "Analysing Characters",
StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipReview => "Building Relationships",
StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.AssetReview => "Analysing Assets",
StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.LocationReview => "Analysing Locations",
StoryIntelligenceFailureStages.DomainImport or StoryIntelligencePipelineStages.KnowledgeImport or StoryIntelligencePipelineStages.KnowledgeReview => "Inferring Knowledge",
StoryIntelligenceFailureStages.Persistence => "Building Continuity",
_ when stage.Contains("Scene", StringComparison.OrdinalIgnoreCase) => $"Analysing {chapterText}{sceneText}",
_ => FirstConfigured(run.CurrentMessage, stage, $"Analysing {chapterText}{sceneText}")
};
}
private static StoryIntelligenceExperienceSnapshotDiagnostics BuildSessionDiagnostics(
StoryIntelligenceBookPipelineState session,
IReadOnlyList<RunSnapshot> snapshots,
RunSnapshot activeSnapshot,
ParsedSceneResult? current,
int parsedScenes,
int totalPersistedResults,
int totalScenes,
int overallProgressPercent,
int authoritativeImportProgress,
int calculatedVisualisationProgress,
int totalChapters,
int completedChapters,
string displayedProgressReason,
StoryMemory storyMemory,
bool replayMode = false,
string appearancePreference = "")
{
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
var failedChapterSnapshots = snapshots
.Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase))
.OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID)
.ToList();
var failedChapterSummary = string.Join("; ", failedChapterSnapshots.Select(snapshot =>
$"Chapter {snapshot.Run.ChapterNumber:0.##} - {FirstConfigured(snapshot.Run.ErrorMessage, snapshot.Run.FailureStage, "analysis failed")}"));
return new()
{
RequestedMode = replayMode ? "replay" : "real",
CurrentImportId = session.StoryIntelligenceBookPipelineID,
RequestedImportSessionId = session.StoryIntelligenceBookPipelineID,
ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID,
ActiveChapterRunId = activeSnapshot.Run.StoryIntelligenceRunID,
SessionStatus = session.Status,
OverallBookProgress = overallProgressPercent,
AuthoritativeImportProgress = authoritativeImportProgress,
CalculatedVisualisationProgress = calculatedVisualisationProgress,
TotalChapters = totalChapters,
CompletedChapters = completedChapters,
CurrentChapter = activeSnapshot.Run.ChapterNumber.HasValue ? $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}" : "Chapter unknown",
CurrentScene = CurrentSceneText(activeSnapshot),
CompletedScenes = parsedScenes,
TotalScenes = totalScenes,
ParsedScenes = parsedScenes,
TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}",
DisplayedProgressReason = replayMode ? "Replay position through available saved scenes" : displayedProgressReason,
ProgressSource = replayMode ? "ReplayCursor" : "ImportSession",
CurrentBackendStage = replayMode ? "Replay saved analysis" : BackendStageDisplay(activeSnapshot.Run),
StoryMemoryCharacterCount = storyMemory.Characters.Count,
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
NarratorConfidence = storyMemory.NarratorConfidence,
RawScenePov = storyMemory.RawScenePov,
InheritedChapterPov = storyMemory.InheritedChapterPov,
ResolvedPovIdentity = storyMemory.ResolvedPovIdentity,
PovResolutionReason = storyMemory.PovResolutionReason,
PovConfidence = storyMemory.PovConfidence,
PovChangeDetected = storyMemory.PovChangeDetected,
SnapshotSource = replayMode ? "Replay" : "Real",
RunStatus = SessionStatus(session, snapshots),
PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
TotalPersistedSceneResultCount = totalPersistedResults,
SuccessfullyParsedSceneResultCount = parsedScenes,
FailedSceneResultCount = failed,
HighestPersistedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Results).Select(result => (int?)result.SceneResultID).Max(),
HighestParsedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Completed).Select(item => (int?)item.Result.SceneResultID).Max(),
SelectedCurrentSceneResultId = current?.Result.SceneResultID,
SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}",
CurrentChangeToken = SessionChangeToken(session, snapshots),
AnalysisCursor = FirstConfigured(activeSnapshot.Run.CurrentMessage, activeSnapshot.Run.CurrentStage, session.CurrentStage),
Warning = replayMode && failedChapterSnapshots.Count > 0
? $"{failedChapterSnapshots.Count:N0} failed chapter(s) skipped during replay. {failedChapterSummary}"
: warning,
ReplayIndex = replayMode ? 1 : 0,
TotalReplayableScenes = replayMode ? parsedScenes : 0,
ReplayMilestone = replayMode ? "Scene completed snapshot" : string.Empty,
ReplayState = replayMode ? "Paused at first available scene" : string.Empty,
SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty,
RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty,
AppearancePreference = appearancePreference,
FailedChaptersSkipped = failedChapterSnapshots.Count,
FailedChapterSummary = failedChapterSummary,
PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty,
PanelHiddenCounts = replayMode ? "computed in browser per replay cursor" : string.Empty,
AiCallsDuringReplay = 0
};
}
private static string ReplaySnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList<RunSnapshot> snapshots, int replayableScenes)
{
var failedChapters = snapshots.Count(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase));
var completedChapters = snapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status));
return $"Saved analysis replay from Import Session {session.StoryIntelligenceBookPipelineID:N0}. {completedChapters:N0} of {snapshots.Count:N0} chapters analysed, {failedChapters:N0} failed chapter(s), {replayableScenes:N0} replayable scene(s). No AI calls are made in replay.";
}
private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics(
StoryIntelligenceSavedRun run,
IReadOnlyList<StoryIntelligenceSavedSceneResult> results,
IReadOnlyList<ParsedSceneResult> parsed,
IReadOnlyList<ParsedSceneResult> completed,
ParsedSceneResult? current,
StoryMemory storyMemory)
{
var failed = parsed.Where(item => item.Scene is null).ToList();
var warning = failed.Count == 0
? null
: $"{failed.Count:N0} scene result(s) skipped because they could not be parsed.";
var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count);
var completedSceneCount = run.CompletedScenes ?? completed.Count;
var runProgress = Progress(run, completedSceneCount, totalScenes);
var calculatedProgress = OverallProgress(completed.Count, totalScenes, IsSuccessfulTerminal(run.Status));
return new()
{
RequestedMode = "real",
ActiveChapterRunId = run.StoryIntelligenceRunID,
SessionStatus = run.Status,
OverallBookProgress = runProgress,
AuthoritativeImportProgress = runProgress,
CalculatedVisualisationProgress = calculatedProgress,
TotalChapters = 1,
CompletedChapters = IsSuccessfulTerminal(run.Status) ? 1 : 0,
CurrentChapter = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter unknown",
CurrentScene = run.TotalDetectedScenes.HasValue
? $"Scene {Math.Clamp(completedSceneCount + (IsTerminal(run.Status) ? 0 : 1), 1, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}"
: (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"),
CompletedScenes = completedSceneCount,
TotalScenes = totalScenes,
ParsedScenes = completed.Count,
TerminalRunSessionStatus = run.Status,
DisplayedProgressReason = IsSuccessfulTerminal(run.Status) ? "Successful terminal chapter run" : "Chapter run scene progress",
ProgressSource = "ChapterRun",
CurrentBackendStage = BackendStageDisplay(run),
StoryMemoryCharacterCount = storyMemory.Characters.Count,
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
NarratorConfidence = storyMemory.NarratorConfidence,
RawScenePov = storyMemory.RawScenePov,
InheritedChapterPov = storyMemory.InheritedChapterPov,
ResolvedPovIdentity = storyMemory.ResolvedPovIdentity,
PovResolutionReason = storyMemory.PovResolutionReason,
PovConfidence = storyMemory.PovConfidence,
PovChangeDetected = storyMemory.PovChangeDetected,
SnapshotSource = "Real",
RunStatus = run.Status,
PersistedCompletedSceneCount = run.CompletedScenes ?? 0,
TotalPersistedSceneResultCount = results.Count,
SuccessfullyParsedSceneResultCount = completed.Count,
FailedSceneResultCount = failed.Count,
HighestPersistedSceneResultId = results.Count == 0 ? null : results.Max(result => result.SceneResultID),
HighestParsedSceneResultId = completed.Count == 0 ? null : completed.Max(item => item.Result.SceneResultID),
SelectedCurrentSceneResultId = current?.Result.SceneResultID,
SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {SceneSortChapter(current):0.##}, Scene {SceneSortNumber(current):0.##}",
CurrentChangeToken = ChangeToken(run, results),
AnalysisCursor = FirstConfigured(run.CurrentMessage, run.CurrentStage),
Warning = warning
};
}
private static string SnapshotMessage(StoryIntelligenceSavedRun run, int completedScenes)
=> run.Status switch
{
StoryIntelligenceRunStatuses.Failed => FirstConfigured(run.ErrorMessage, "Story Intelligence run failed."),
StoryIntelligenceRunStatuses.Cancelled => "Story Intelligence run was cancelled.",
StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings => "Analysis complete and ready for review.",
_ when completedScenes == 0 => "Waiting for the first persisted scene analysis.",
_ => FirstConfigured(run.CurrentMessage, "Analysis snapshot is updating.")
};
private async Task ApplyPersistedStoryMemoryAssignmentsAsync(int importSessionId, StoryIntelligenceExperiencePrototypeViewModel model)
{
var assignments = await storyMemoryRepository.ListAssignmentsAsync(importSessionId);
if (assignments.Count == 0)
{
return;
}
var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId);
var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId);
var assets = await storyMemoryRepository.ListAssetsAsync(importSessionId);
var attributes = await storyMemoryRepository.ListCharacterAttributesAsync(importSessionId);
var importContext = await storyMemoryRepository.GetImportContextAsync(importSessionId);
var characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey);
var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey);
var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, item => item.CanonicalIdentityKey);
var characterEvidence = characters.ToDictionary(
item => item.CanonicalIdentityKey,
item =>
{
var evidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
item.DisplayName,
attributes.Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID));
return StoryMemoryService.ApplyImportContextToEvidence(evidence, item.DisplayName, importContext);
},
StringComparer.OrdinalIgnoreCase);
var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter()))
.ToDictionary(item => item.IllustrationLibraryItemID);
var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue, characterEvidence);
var locationAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Location, locationIds, catalogue);
var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue);
foreach (var scene in model.Scenes)
{
ApplyLocationAssignment(scene.Location, locationAssignments);
foreach (var character in scene.Characters)
{
ApplyCharacterAssignment(character, characterAssignments);
}
foreach (var asset in scene.Assets)
{
ApplyAssetAssignment(asset, assetAssignments);
}
}
}
private Dictionary<string, AssignmentImage> BuildAssignmentLookup(
IReadOnlyList<StoryMemoryIllustrationAssignment> assignments,
string entityType,
IReadOnlyDictionary<int, string> entityKeys,
IReadOnlyDictionary<int, IllustrationLibraryItem> catalogue,
IReadOnlyDictionary<string, CharacterEvidenceProfile>? characterEvidence = null)
{
var lookup = new Dictionary<string, AssignmentImage>(StringComparer.OrdinalIgnoreCase);
foreach (var assignment in assignments.Where(item => item.EntityType == entityType && item.IllustrationLibraryItemID.HasValue))
{
if (!entityKeys.TryGetValue(assignment.EntityID, out var entityKey)
|| !catalogue.TryGetValue(assignment.IllustrationLibraryItemID!.Value, out var item))
{
continue;
}
var publicUrl = ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath);
if (string.IsNullOrWhiteSpace(publicUrl))
{
continue;
}
CharacterEvidenceProfile? evidence = null;
characterEvidence?.TryGetValue(entityKey, out evidence);
if (entityType == StoryMemoryEntityTypes.Character && evidence is not null)
{
var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
var rejections = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, true, false);
if (rejections.Count > 0)
{
continue;
}
}
var currentEvidence = evidence is null
? null
: $"age={evidence.AgeBand}; presentation={evidence.Presentation}; hair={evidence.HairColour}; hairLength={evidence.HairLength}; skin={evidence.SkinTone}";
lookup[entityKey] = new AssignmentImage(
item.IllustrationLibraryItemID,
item.StableCode,
item.Status,
publicUrl,
assignment.Confidence,
assignment.MatchScore,
assignment.AssignmentReason,
evidence?.AgeBand,
evidence?.Presentation,
currentEvidence,
assignment.EvidenceVersion,
assignment.AssignmentStatus,
assignment.IsFallback);
}
return lookup;
}
private void ApplyCharacterAssignment(StoryIntelligenceExperienceCharacterState character, IReadOnlyDictionary<string, AssignmentImage> assignments)
{
if (!assignments.TryGetValue(character.Id, out var assignment))
{
return;
}
var fallback = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) ? character.ImagePath : character.ImageResolution.FallbackImageUrl;
character.LibraryCode = assignment.StableCode;
character.ImagePath = assignment.PublicUrl;
character.ImageResolution = BuildAssignmentResolution(character.Id, assignment, fallback, "Persisted Story Memory character assignment");
}
private void ApplyLocationAssignment(StoryIntelligenceExperienceLocationState location, IReadOnlyDictionary<string, AssignmentImage> assignments)
{
if (!assignments.TryGetValue(location.Id, out var assignment))
{
return;
}
var fallback = string.IsNullOrWhiteSpace(location.ImageResolution.FallbackImageUrl) ? location.ImagePath : location.ImageResolution.FallbackImageUrl;
location.LibraryCode = assignment.StableCode;
location.ImagePath = assignment.PublicUrl;
location.ImageResolution = BuildAssignmentResolution(location.Id, assignment, fallback, "Persisted Story Memory location assignment");
}
private void ApplyAssetAssignment(StoryIntelligenceExperienceAssetState asset, IReadOnlyDictionary<string, AssignmentImage> assignments)
{
if (!assignments.TryGetValue(asset.Id, out var assignment))
{
return;
}
var fallback = string.IsNullOrWhiteSpace(asset.ImageResolution.FallbackImageUrl) ? asset.ImagePath : asset.ImageResolution.FallbackImageUrl;
asset.LibraryCode = assignment.StableCode;
asset.ImagePath = assignment.PublicUrl;
asset.ImageResolution = BuildAssignmentResolution(asset.Id, assignment, fallback, "Persisted Story Memory asset assignment");
}
private static StoryIntelligenceExperienceImageResolution BuildAssignmentResolution(string entityId, AssignmentImage assignment, string fallback, string status)
=> new()
{
EntityId = entityId,
StableCode = assignment.StableCode,
IllustrationLibraryItemId = assignment.IllustrationLibraryItemId,
Status = assignment.Status,
FinalImageUrl = assignment.PublicUrl,
FallbackImageUrl = fallback,
FallbackUsed = false,
AssignmentConfidence = assignment.Confidence,
MatchingScore = assignment.MatchScore,
ReasonChosen = assignment.Reason,
PreviousIllustration = assignment.StableCode,
CurrentEvidence = assignment.CurrentEvidence,
PreviousEvidence = $"assignment evidence version {assignment.EvidenceVersion}",
ResolvedAgeBand = assignment.ResolvedAgeBand,
ResolvedPresentation = assignment.ResolvedPresentation,
DemandStatus = "Read from durable Story Memory",
IllustrationAllocationStatus = assignment.IsFallback ? assignment.AssignmentStatus : status
};
private string? ResolveExistingPublicUploadUrl(string? storedPath)
{
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
return physicalPath is not null && File.Exists(physicalPath) ? storedPath : null;
}
private static void StabiliseCharacterIllustrations(StoryIntelligenceExperiencePrototypeViewModel model)
{
var firstAssignments = new Dictionary<string, (string LibraryCode, string ImagePath)>(StringComparer.OrdinalIgnoreCase);
foreach (var scene in model.Scenes.OrderBy(scene => scene.SceneNumber))
{
foreach (var character in scene.Characters)
{
if (string.IsNullOrWhiteSpace(character.Id))
{
continue;
}
if (firstAssignments.TryGetValue(character.Id, out var assignment))
{
character.LibraryCode = assignment.LibraryCode;
character.ImagePath = assignment.ImagePath;
continue;
}
firstAssignments[character.Id] = (character.LibraryCode, character.ImagePath);
}
}
}
private static void ApplyIllustrationDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model)
{
var characterResolutions = model.Scenes
.SelectMany(scene => scene.Characters)
.Select(character => character.ImageResolution)
.Where(resolution => resolution.IllustrationLibraryItemId.HasValue)
.ToList();
model.Diagnostics.IllustrationAssignmentCount = characterResolutions
.Select(resolution => resolution.IllustrationLibraryItemId!.Value)
.Distinct()
.Count();
model.Diagnostics.IllustrationChangeCount = characterResolutions.Count(resolution => !string.IsNullOrWhiteSpace(resolution.ReasonReassigned));
var uniqueCharacters = model.Scenes
.SelectMany(scene => scene.Characters)
.GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(character => character.Name)
.ToList();
var reuseCounts = uniqueCharacters
.Where(character => character.ImageResolution.IllustrationLibraryItemId.HasValue)
.GroupBy(character => character.ImageResolution.IllustrationLibraryItemId!.Value)
.ToDictionary(group => group.Key, group => group.Count());
model.Diagnostics.CharacterConsistencyReport = string.Join(" | ", uniqueCharacters.Select(character =>
{
var resolution = character.ImageResolution;
var reuseCount = resolution.IllustrationLibraryItemId.HasValue && reuseCounts.TryGetValue(resolution.IllustrationLibraryItemId.Value, out var count)
? count
: 0;
var allocation = reuseCount <= 1
? resolution.IllustrationAllocationStatus ?? "Unique allocation"
: $"Reused {reuseCount:N0} times";
return $"{character.Name}: gender={FirstConfigured(resolution.ResolvedPresentation, "Unknown")}, age={FirstConfigured(resolution.ResolvedAgeBand, "Unknown")}, relationship={resolution.RelationshipDerivedConfidence ?? 0:0.##}, name={resolution.NameDerivedConfidence ?? 0:0.##}, pronoun={resolution.PronounDerivedConfidence ?? 0:0.##}, illustration={FirstConfigured(resolution.StableCode, character.LibraryCode, "none")}, reuse={reuseCount:N0}, allocation={allocation}";
}));
}
public static void StripLiveDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model)
{
model.Diagnostics.CharacterConsistencyReport = Trim(model.Diagnostics.CharacterConsistencyReport, 2000);
foreach (var resolution in model.Scenes
.SelectMany(scene => scene.Characters.Select(character => character.ImageResolution)
.Concat(scene.Assets.Select(asset => asset.ImageResolution))
.Append(scene.Location.ImageResolution)))
{
StripLiveDiagnostics(resolution);
}
}
private static void StripLiveDiagnostics(StoryIntelligenceExperienceImageResolution resolution)
{
if (resolution is null)
{
return;
}
resolution.RejectedCandidates = null;
resolution.PreviousEvidence = null;
resolution.CurrentEvidence = null;
resolution.EvidenceWarnings = null;
resolution.ReasonChosen = Trim(resolution.ReasonChosen, 160);
resolution.ReasonReassigned = Trim(resolution.ReasonReassigned, 160);
resolution.PreviousIllustration = Trim(resolution.PreviousIllustration, 80);
resolution.DemandKey = Trim(resolution.DemandKey, 80);
resolution.DemandStatus = Trim(resolution.DemandStatus, 80);
}
private static IReadOnlySet<int> VisibleSceneResultIds(
IReadOnlyList<ParsedSceneResult> completed,
ParsedSceneResult? current,
int previousCount = SceneWindowPrevious,
int followingCount = SceneWindowFollowing)
{
if (completed.Count == 0)
{
return new HashSet<int>();
}
var currentIndex = current is null
? completed.Count - 1
: completed.ToList().FindIndex(item => item.Result.SceneResultID == current.Result.SceneResultID);
if (currentIndex < 0)
{
currentIndex = completed.Count - 1;
}
var start = Math.Max(0, currentIndex - previousCount);
var end = Math.Min(completed.Count - 1, currentIndex + followingCount);
return completed
.Skip(start)
.Take(end - start + 1)
.Select(item => item.Result.SceneResultID)
.ToHashSet();
}
private void LogPayload(StoryIntelligenceExperiencePrototypeViewModel model, string scope, long elapsedMs)
{
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(model, JsonOptions).Length;
var counts = new
{
sceneCount = model.Scenes.Count,
characterCount = model.Scenes.Sum(scene => scene.Characters.Count),
locationCount = model.Scenes.Count(scene => !string.IsNullOrWhiteSpace(scene.Location.Name)),
assetCount = model.Scenes.Sum(scene => scene.Assets.Count),
relationshipCount = model.Scenes.Sum(scene => scene.Relationships.Count),
knowledgeCount = model.Scenes.Sum(scene => scene.KnowledgeThreads.Count),
observationCount = model.Scenes.Sum(scene => scene.Observations.Count),
activityCount = model.Scenes.Sum(scene => scene.ActivityFeed.Count)
};
var largestString = LargestStringProperty(model);
if (payloadBytes > SnapshotWarningBytes)
{
logger.LogWarning(
"Story Intelligence snapshot payload for {Scope} is {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters; built in {ElapsedMs}ms.",
scope,
payloadBytes,
counts,
largestString.Path,
largestString.Length,
elapsedMs);
return;
}
logger.LogInformation(
"Built Story Intelligence visualisation snapshot for {Scope} in {ElapsedMs}ms; payload {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters.",
scope,
elapsedMs,
payloadBytes,
counts,
largestString.Path,
largestString.Length);
}
private void LogSnapshotSynchronisation(
int importSessionId,
RunSnapshot activeSnapshot,
ParsedSceneResult? current,
IReadOnlyList<StoryIntelligenceExperienceSceneState> scenes,
string changeToken)
{
var displayed = scenes.FirstOrDefault(scene => scene.IsCurrent) ?? scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence snapshot synchronisation: import session {ImportSessionId}; active run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; selected scene result {SceneResultId}; displayed scene id {DisplayedSceneId}; change token {ChangeToken}; payload scenes {SceneIds}.",
importSessionId,
activeSnapshot.Run.StoryIntelligenceRunID,
activeSnapshot.Run.ChapterNumber,
current is null ? null : SceneSortNumber(current),
current?.Result.SceneResultID,
displayed?.Id,
changeToken,
string.Join(",", scenes.Select(scene => scene.Id)));
}
private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model)
{
var largest = ("model", 0);
void Consider(string path, string? value)
{
if (value?.Length > largest.Item2)
{
largest = (path, value.Length);
}
}
Consider("snapshotMessage", model.SnapshotMessage);
Consider("diagnostics.characterConsistencyReport", model.Diagnostics.CharacterConsistencyReport);
foreach (var scene in model.Scenes)
{
Consider($"{scene.Id}.summary", scene.Summary);
foreach (var character in scene.Characters)
{
Consider($"{scene.Id}.character.{character.Id}.name", character.Name);
Consider($"{scene.Id}.character.{character.Id}.relevance", character.Relevance);
Consider($"{scene.Id}.character.{character.Id}.rejectedCandidates", character.ImageResolution.RejectedCandidates);
}
}
return (largest.Item1, largest.Item2);
}
private static bool IsTerminal(string status)
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;
private static bool IsSuccessfulTerminal(string status)
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings;
private static bool IsSuccessfulSession(StoryIntelligenceBookPipelineState session)
=> string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
&& (string.Equals(session.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase)
|| string.Equals(session.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
|| session.CompletedUtc.HasValue);
private static bool IsTerminalSession(StoryIntelligenceBookPipelineState session)
=> IsSuccessfulSession(session);
private static int SceneNumber(ParsedSceneResult item)
=> item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber;
private static decimal SceneSortChapter(ParsedSceneResult item)
=> item.Scene?.SceneReference?.ChapterNumber
?? (item.Result.ChapterID.HasValue ? item.Result.ChapterID.Value : 0);
private static decimal SceneSortNumber(ParsedSceneResult item)
=> item.Scene?.SceneReference?.SceneNumber ?? item.Result.TemporarySceneNumber;
private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber)
=> CompactSceneTitle(FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}"), sceneNumber);
private static string CompactSceneTitle(string title, int sceneNumber)
{
var value = Clean(title);
if (string.IsNullOrWhiteSpace(value))
{
return $"Scene {sceneNumber:N0}";
}
var sceneMarker = $"scene {sceneNumber}".ToLowerInvariant();
var lower = value.ToLowerInvariant();
var markerIndex = lower.IndexOf(sceneMarker, StringComparison.Ordinal);
if (markerIndex > 0)
{
value = value[markerIndex..].Trim(' ', '-', ':', '|');
}
var separators = new[] { " - ", " | ", ": " };
foreach (var separator in separators)
{
var parts = value.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 3)
{
return Trim(parts[^1], 72);
}
}
return Trim(value, 72);
}
private static string TimelineLabel(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result)
=> FirstConfigured(scene.TimelineClues?.FirstOrDefault()?.AbsoluteDate, scene.Setting?.DateOrTimeReference, result.SourceLabel, "Known scene");
private static string ChapterLabel(StoryIntelligenceSavedRun run, ParsedSceneResult item)
{
var chapter = item.Scene?.SceneReference?.ChapterNumber ?? run.ChapterNumber;
return chapter.HasValue ? $"Chapter {chapter:0.##}" : "Chapter";
}
private static int Progress(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes)
{
if (IsTerminal(run.Status) && run.Status is not StoryIntelligenceRunStatuses.Failed)
{
return 100;
}
if (totalScenes > 0)
{
return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99);
}
return run.Status == StoryIntelligenceRunStatuses.Running ? 8 : 0;
}
private static int OverallProgress(int completedScenes, int totalScenes, bool isTerminal)
{
if (isTerminal)
{
return 100;
}
if (totalScenes > 0)
{
return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99);
}
return 0;
}
private static int BookProgress(IReadOnlyList<RunSnapshot> snapshots, int chapterTotal, bool isTerminal)
{
if (isTerminal)
{
return 100;
}
if (chapterTotal <= 0)
{
return 0;
}
var chapterProgress = snapshots.Sum(ChapterProgress);
return Math.Clamp((int)Math.Floor(chapterProgress * 100m / chapterTotal), 0, 99);
}
private static decimal ChapterProgress(RunSnapshot snapshot)
{
if (IsSuccessfulTerminal(snapshot.Run.Status))
{
return 1m;
}
if (snapshot.Run.Status == StoryIntelligenceRunStatuses.Pending)
{
return 0m;
}
if ((snapshot.Run.TotalDetectedScenes ?? 0) > 0)
{
var sceneProgress = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) / Math.Max(1m, snapshot.Run.TotalDetectedScenes ?? 1), 0m, 1m);
return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m);
}
return 0.15m;
}
private static string EstimateRemaining(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes)
{
if (IsTerminal(run.Status))
{
return run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings ? "Ready to review" : run.Status;
}
if (completedScenes <= 0 || totalScenes <= completedScenes)
{
return "Calculating";
}
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
var averageMs = elapsedMs / Math.Max(1, completedScenes);
return FormatDuration(averageMs * (totalScenes - completedScenes));
}
private static string EstimateElapsed(StoryIntelligenceSavedRun run)
{
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
return FormatDuration(elapsedMs);
}
private static long SessionElapsedMilliseconds(IReadOnlyList<RunSnapshot> snapshots, bool isTerminal)
{
if (snapshots.Count == 0)
{
return 0;
}
var started = snapshots.Min(snapshot => snapshot.Run.StartedUtc);
var ended = isTerminal
? snapshots
.Select(snapshot => snapshot.Run.CompletedUtc ?? snapshot.Run.UpdatedUtc)
.DefaultIfEmpty(DateTime.UtcNow)
.Max()
: DateTime.UtcNow;
return Math.Max(0, Convert.ToInt64((ended - started).TotalMilliseconds));
}
private static string FormatDuration(long milliseconds)
{
var duration = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds));
return duration.TotalMinutes >= 1 ? $"{Math.Max(1, (int)Math.Round(duration.TotalMinutes)):N0} min" : $"{Math.Max(0, (int)Math.Round(duration.TotalSeconds)):N0} sec";
}
private static int ChapterCount(IEnumerable<ParsedSceneResult> completed)
=> Math.Max(1, completed.Select(item => item.Result.ChapterID ?? 0).Where(id => id > 0).Distinct().Count());
private static string StageDisplay(StoryIntelligenceSavedRun run)
=> FirstConfigured(run.CurrentStage, run.Status);
private static StoryIntelligenceExperienceLocationState PendingLocation()
=> new()
{
Id = "location-pending",
LibraryCode = string.Empty,
Name = "Location not yet identified",
Label = "Awaiting scene location",
ImagePath = $"{FallbackRoot}/location-flat.svg"
};
private static string CharacterId(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}";
private static string CanonicalCharacterIdentity(string? name)
{
var clean = Clean(name).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(clean))
{
return "unknown";
}
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length == 2
&& !IsTitleWord(words[0])
&& name is not null
&& name.Trim().Length > 0
&& char.IsUpper(name.Trim()[0])
&& name.Trim().All(ch => char.IsLetter(ch) || char.IsWhiteSpace(ch) || ch == '\'' || ch == '\u2019'))
{
return words[0];
}
if (clean is "mother" or "mum" or "mummy" or "her mother" or "the mother" or "my mother" or "my mum"
|| clean.Contains("beth's mum", StringComparison.Ordinal)
|| clean.Contains("beth's mother", StringComparison.Ordinal))
{
return "beth mother";
}
if (clean is "aunt" or "her aunt" or "my aunt" or "aunt elen" || clean.Contains("beth's aunt", StringComparison.Ordinal))
{
return "beth aunt";
}
if (IsGenericNarratorName(clean))
{
return "narrator";
}
return clean;
}
private static bool IsTitleWord(string value)
=> value is "mr" or "mrs" or "miss" or "ms" or "dr" or "doctor" or "sir" or "lady";
private static int CharacterWeight(SceneIntelligenceCharacter character)
=> character.MentionedOnly == true ? 46 : ConfidenceWeight(character.Confidence, 72);
private static int AssetWeight(SceneIntelligenceAsset asset)
=> ConfidenceWeight(asset.Confidence, string.IsNullOrWhiteSpace(asset.Status) ? 64 : 82);
private static int ConfidenceWeight(decimal? confidence, int fallback)
=> Math.Clamp(confidence.HasValue ? (int)Math.Round(confidence.Value * 100m) : fallback, 30, 100);
private static int Specificity(string? value)
=> string.IsNullOrWhiteSpace(value) ? 0 : value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
private static bool IsLowValueAsset(SceneIntelligenceAsset asset)
{
var value = $"{asset.Name} {asset.AssetType} {asset.Notes}".ToLowerInvariant();
return value.Contains("background", StringComparison.OrdinalIgnoreCase)
|| value.Contains("ordinary", StringComparison.OrdinalIgnoreCase)
|| value is "door" or "table" or "chair";
}
private static string CharacterLibraryCode(
string name,
SceneIntelligenceCharacter? character,
SceneIntelligenceScene? scene = null,
IReadOnlySet<string>? reservedCodes = null)
{
var profile = CharacterProfile.From(name, character, scene);
var candidates = CharacterCodeProfiles
.OrderByDescending(candidate => candidate.Score(profile))
.ThenBy(candidate => reservedCodes?.Contains(candidate.Code) == true ? 1 : 0)
.ThenBy(candidate => StableHash($"{name}|{candidate.Code}"))
.ToList();
return candidates.FirstOrDefault(candidate => reservedCodes?.Contains(candidate.Code) != true)?.Code
?? candidates.First().Code;
}
private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting)
{
var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
var type = StoryIntelligenceIllustrationCompatibility.LocationType(value);
if (type == "Bathroom") return "loc-generic-bathroom";
if (type == "Kitchen") return "loc-generic-kitchen";
if (type == "WaitingRoom") return "loc-generic-waiting-room";
if (type == "DrivingTestCentre") return "loc-driving-test-centre";
if (type == "VehicleInterior") return "loc-vehicle-interior";
if (type is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "Motorway" or "Bridge" or "Flyover" or "Underpass") return "loc-generic-road";
if (type == "Garage") return "loc-garage-workshop";
if (type is "Hospital" or "PoliceStation" or "School" or "Church" or "Restaurant" or "Pub") return $"loc-generic-{StableKey(type)}";
if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior";
if (type == "Office") return "loc-domestic-desk";
if (type == "Stairwell") return "loc-narrow-stairwell";
if (type is "CarPark") return "loc-wet-car-park";
if (type == "HouseExterior") return "loc-estate-house-exterior";
return string.Empty;
}
private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset)
{
var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant();
var type = StoryIntelligenceIllustrationCompatibility.AssetType(value);
if (type is "Letter" or "Note" or "Document" or "Certificate" or "Passport" or "DrivingLicence") return "asset-document";
if (type is "Envelope") return "asset-envelope";
if (type is "Notebook") return "asset-worn-notebook";
if (type is "Book" or "DrivingManual" or "Newspaper" or "Magazine") return "asset-book";
if (type == "Car") return "asset-red-classic-car";
if (type == "Keys") return "asset-old-keys";
if (type == "Telephone") return "asset-phone-recorder";
if (type == "Photograph") return "asset-photo-print";
if (type is "Painting") return "asset-photo-print";
if (type == "Bottle") return "asset-bottle";
if (type == "Clock") return "asset-clock";
if (type == "TrafficSign") return "asset-traffic-sign";
if (type is "Wallet" or "Handbag" or "Suitcase") return "asset-bag-wallet";
if (type is "Parcel" or "Gift") return "asset-parcel";
if (type is "Bench") return "asset-bench";
if (type is "Tree" or "Lamp" or "Window" or "Door" or "Fence") return "asset-environment-object";
if (type == "Rucksack") return "asset-rucksack";
return string.Empty;
}
private static string CharacterFallback(string name)
=> CharacterLibraryCode(name, null) switch
{
"char-young-adult-red-haired-witness" => $"{FallbackRoot}/character-rosie.svg",
"char-middle-aged-weathered-man" => $"{FallbackRoot}/character-graham.svg",
"char-older-soft-presence" => $"{FallbackRoot}/character-maggie.svg",
"char-young-helper" => $"{FallbackRoot}/character-beth.svg",
"char-archival-contact" => $"{FallbackRoot}/character-graham.svg",
"char-professional-investigator" => $"{FallbackRoot}/character-graham.svg",
"char-neighbourhood-friend" => $"{FallbackRoot}/character-beth.svg",
"char-family-relative" => $"{FallbackRoot}/character-maggie.svg",
"char-quiet-antagonist" => $"{FallbackRoot}/character-graham.svg",
"char-authorial-voice" => $"{FallbackRoot}/character-maggie.svg",
"char-unknown-figure" => $"{FallbackRoot}/character-beth.svg",
_ => $"{FallbackRoot}/character-beth.svg"
};
private static string LocationFallback(string name)
=> LocationLibraryCode(name, null, null) switch
{
"loc-laundry-utility-room" => $"{FallbackRoot}/location-laundry.svg",
"loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg",
"loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg",
"loc-estate-house-exterior" => $"{FallbackRoot}/location-doweries.svg",
"loc-generic-road" => $"{FallbackRoot}/location-road.svg",
"loc-vehicle-interior" => $"{FallbackRoot}/location-vehicle-interior.svg",
"loc-generic-bathroom" => $"{FallbackRoot}/location-bathroom.svg",
"loc-generic-kitchen" => $"{FallbackRoot}/location-kitchen.svg",
"loc-generic-waiting-room" => $"{FallbackRoot}/location-waiting-room.svg",
"loc-driving-test-centre" => $"{FallbackRoot}/location-driving-test-centre.svg",
"loc-garage-workshop" => $"{FallbackRoot}/location-garage-workshop.svg",
"loc-generic-hospital" => $"{FallbackRoot}/location-building.svg",
"loc-generic-policestation" => $"{FallbackRoot}/location-building.svg",
"loc-generic-school" => $"{FallbackRoot}/location-building.svg",
"loc-generic-church" => $"{FallbackRoot}/location-building.svg",
"loc-generic-restaurant" => $"{FallbackRoot}/location-building.svg",
"loc-generic-pub" => $"{FallbackRoot}/location-building.svg",
_ => $"{FallbackRoot}/location-flat.svg"
};
private static string AssetFallback(string name)
=> AssetLibraryCode(name, new SceneIntelligenceAsset()) switch
{
"asset-folded-letter" => $"{FallbackRoot}/asset-letter.svg",
"asset-document" => $"{FallbackRoot}/asset-document.svg",
"asset-envelope" => $"{FallbackRoot}/asset-envelope.svg",
"asset-worn-notebook" => $"{FallbackRoot}/asset-notebook.svg",
"asset-book" => $"{FallbackRoot}/asset-book.svg",
"asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg",
"asset-old-keys" => $"{FallbackRoot}/asset-keys.svg",
"asset-phone-recorder" => $"{FallbackRoot}/asset-phone.svg",
"asset-photo-print" => $"{FallbackRoot}/asset-photo.svg",
"asset-bottle" => $"{FallbackRoot}/asset-bottle.svg",
"asset-clock" => $"{FallbackRoot}/asset-clock.svg",
"asset-traffic-sign" => $"{FallbackRoot}/asset-traffic-sign.svg",
"asset-bag-wallet" => $"{FallbackRoot}/asset-bag-wallet.svg",
"asset-parcel" => $"{FallbackRoot}/asset-parcel.svg",
"asset-bench" => $"{FallbackRoot}/asset-bench.svg",
"asset-environment-object" => $"{FallbackRoot}/asset-environment.svg",
_ => $"{FallbackRoot}/asset-generic.svg"
};
private static string StableKey(string? value)
{
var clean = Clean(value).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(clean))
{
return "unknown";
}
var builder = new StringBuilder();
foreach (var ch in clean)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(ch);
}
else if (builder.Length > 0 && builder[^1] != '-')
{
builder.Append('-');
}
}
return builder.ToString().Trim('-');
}
private static int StableHash(string? value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
return BitConverter.ToInt32(bytes, 0) & int.MaxValue;
}
private static string StableContentHash(string? value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)))[..16].ToLowerInvariant();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Trim();
private static string FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
private static string DisplayTitle(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
private static string Trim(string? value, int max)
{
var clean = Clean(value);
return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "...";
}
private sealed record RunSnapshot(
StoryIntelligenceSavedRun Run,
IReadOnlyList<StoryIntelligenceSavedSceneResult> Results,
IReadOnlyList<ParsedSceneResult> Parsed,
IReadOnlyList<ParsedSceneResult> Completed,
ParsedSceneResult? Current);
private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);
private sealed record AssignmentImage(
int IllustrationLibraryItemId,
string StableCode,
string Status,
string PublicUrl,
decimal Confidence,
int MatchScore,
string? Reason,
string? ResolvedAgeBand,
string? ResolvedPresentation,
string? CurrentEvidence,
string EvidenceVersion,
string AssignmentStatus,
bool IsFallback);
private sealed record CharacterCodeProfile(
string Code,
string AgeBand,
string Presentation,
string HairColour,
string HairLength,
IReadOnlyList<string> Signals)
{
public int Score(CharacterProfile profile)
{
var score = 0;
if (profile.AgeBand == AgeBand) score += 80;
else if (CompatibleAge(profile.AgeBand, AgeBand)) score += 30;
else score -= 45;
if (!string.IsNullOrWhiteSpace(profile.Presentation) && Presentation.Equals(profile.Presentation, StringComparison.OrdinalIgnoreCase)) score += 24;
if (!string.IsNullOrWhiteSpace(profile.HairColour) && HairColour.Equals(profile.HairColour, StringComparison.OrdinalIgnoreCase)) score += 18;
if (!string.IsNullOrWhiteSpace(profile.HairLength) && HairLength.Equals(profile.HairLength, StringComparison.OrdinalIgnoreCase)) score += 10;
score += Signals.Count(signal => profile.Text.Contains(signal, StringComparison.OrdinalIgnoreCase)) * 14;
return score;
}
private static bool CompatibleAge(string required, string candidate)
=> (required, candidate) switch
{
("Teen", "YoungAdult") => true,
("YoungAdult", "Teen") => true,
("MiddleAged", "Older") => true,
("Older", "MiddleAged") => true,
_ => false
};
}
private sealed record CharacterProfile(string Text, string AgeBand, string Presentation, string HairColour, string HairLength)
{
public static CharacterProfile From(string name, SceneIntelligenceCharacter? character, SceneIntelligenceScene? scene)
{
var relationshipText = string.Join(' ', (scene?.Relationships ?? [])
.Where(relationship => string.Equals(Clean(relationship.CharacterA), Clean(name), StringComparison.OrdinalIgnoreCase)
|| string.Equals(Clean(relationship.CharacterB), Clean(name), StringComparison.OrdinalIgnoreCase))
.Select(relationship => $"{relationship.RelationshipSignal} {relationship.Evidence}"));
var text = $"{name} {character?.RoleInScene} {character?.Notes} {string.Join(' ', character?.Actions ?? [])} {relationshipText}".ToLowerInvariant();
return new(
text,
DetectAgeBand(text),
DetectPresentation(text),
DetectHairColour(text),
DetectHairLength(text));
}
private static string DetectAgeBand(string text)
{
if (text.Contains("grandmother") || text.Contains("grandfather") || text.Contains("elderly") || text.Contains("older") || text.Contains("retired")) return "Older";
if (text.Contains("mother") || text.Contains("father") || text.Contains("parent") || text.Contains("teacher") || text.Contains("aunt") || text.Contains("uncle") || text.Contains("middle-aged") || text.Contains("middle aged")) return "MiddleAged";
if (text.Contains("teen") || text.Contains("teenage") || text.Contains("15") || text.Contains("fifteen") || text.Contains("schoolgirl") || text.Contains("schoolboy") || text.Contains("child") || text.Contains("daughter") || text.Contains("son")) return "Teen";
if (text.Contains("young") || text.Contains("student")) return "YoungAdult";
return "YoungAdult";
}
private static string DetectPresentation(string text)
{
if (text.Contains("mother") || text.Contains("daughter") || text.Contains("woman") || text.Contains("girl") || text.Contains("female") || text.Contains("sister") || text.Contains("aunt")) return "Feminine";
if (text.Contains("father") || text.Contains("son") || text.Contains("man") || text.Contains("male") || text.Contains("brother") || text.Contains("uncle")) return "Masculine";
return string.Empty;
}
private static string DetectHairColour(string text)
{
if (text.Contains("red") || text.Contains("auburn") || text.Contains("ginger")) return "Red";
if (text.Contains("grey") || text.Contains("gray") || text.Contains("white-haired") || text.Contains("white hair")) return "Grey";
if (text.Contains("black hair") || text.Contains("black-haired")) return "Black";
if (text.Contains("dark") || text.Contains("brown") || text.Contains("brunette")) return "Brown";
return string.Empty;
}
private static string DetectHairLength(string text)
{
if (text.Contains("long hair") || text.Contains("long-haired")) return "Long";
if (text.Contains("short hair") || text.Contains("short-haired")) return "Short";
return string.Empty;
}
}
private static readonly IReadOnlyList<CharacterCodeProfile> CharacterCodeProfiles =
[
new("char-young-helper", "Teen", "Androgynous", "Black", "Short", ["teen", "child", "daughter", "son", "helper"]),
new("char-young-adult-red-haired-witness", "YoungAdult", "Feminine", "Red", "Long", ["red", "auburn", "witness", "protective"]),
new("char-young-adult-brunette-observer", "YoungAdult", "Feminine", "Brown", "Medium", ["young", "observer", "student"]),
new("char-neighbourhood-friend", "YoungAdult", "Androgynous", "Brown", "Medium", ["friend", "neighbour", "neighbor"]),
new("char-family-relative", "MiddleAged", "Feminine", "Brown", "Medium", ["mother", "parent", "aunt", "family", "relative"]),
new("char-professional-investigator", "MiddleAged", "Androgynous", "Brown", "Short", ["teacher", "professional", "investigator", "authority"]),
new("char-middle-aged-weathered-man", "MiddleAged", "Masculine", "DarkBrown", "Short", ["father", "man", "suspect", "threat", "weathered"]),
new("char-quiet-antagonist", "MiddleAged", "Androgynous", "DarkBrown", "Short", ["antagonist", "threat", "pressure", "controlled"]),
new("char-older-soft-presence", "Older", "Feminine", "Grey", "Short", ["grandmother", "older", "remembered", "mother"]),
new("char-archival-contact", "Older", "Masculine", "White", "Short", ["grandfather", "older", "archive", "records"]),
new("char-authorial-voice", "Older", "Androgynous", "Grey", "Medium", ["voice", "memory", "recorded"]),
new("char-unknown-figure", "YoungAdult", "Androgynous", "Brown", "Medium", ["unknown", "unidentified"])
];
}