2181 lines
105 KiB
C#
2181 lines
105 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<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId);
|
|
Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId);
|
|
}
|
|
|
|
public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|
IStoryIntelligenceResultRepository repository,
|
|
IStoryIntelligencePipelineRepository pipelines,
|
|
IStoryIntelligenceSourceRepository sources,
|
|
IIllustrationLibraryService illustrationLibrary,
|
|
IStoryIntelligenceIllustrationMatchingService illustrationMatcher,
|
|
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);
|
|
if (run.ProjectID.HasValue)
|
|
{
|
|
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
|
|
ApplyIllustrationDiagnostics(model);
|
|
}
|
|
StripLiveDiagnostics(model);
|
|
LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds);
|
|
return model;
|
|
}
|
|
|
|
public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId)
|
|
{
|
|
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 current = 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 allCompleted = runSnapshots
|
|
.SelectMany(snapshot => snapshot.Completed)
|
|
.OrderBy(item => item.Result.CreatedUtc)
|
|
.ThenBy(item => item.Result.SceneResultID)
|
|
.ToList();
|
|
var visibleSceneIds = 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 (scenes.Count == 0)
|
|
{
|
|
scenes.Add(BuildEmptyScene(activeSnapshot.Run));
|
|
}
|
|
|
|
var model = new StoryIntelligenceExperiencePrototypeViewModel
|
|
{
|
|
Mode = "real",
|
|
ImportSessionId = session.StoryIntelligenceBookPipelineID,
|
|
RunId = currentSnapshot.Run.StoryIntelligenceRunID,
|
|
BookId = session.BookID,
|
|
RunStatus = SessionStatus(session, runSnapshots),
|
|
GeneratedUtc = DateTime.UtcNow,
|
|
ChangeToken = SessionChangeToken(session, runSnapshots),
|
|
IsTerminal = isTerminal,
|
|
SnapshotMessage = SessionSnapshotMessage(session, runSnapshots),
|
|
Diagnostics = BuildSessionDiagnostics(
|
|
session,
|
|
runSnapshots,
|
|
currentSnapshot,
|
|
current,
|
|
parsedScenes,
|
|
totalPersistedResults,
|
|
totalScenes,
|
|
overallProgressPercent,
|
|
authoritativeImportProgress,
|
|
calculatedVisualisationProgress,
|
|
bookChapterTotal,
|
|
completedChapterRuns,
|
|
displayedProgressReason,
|
|
storyMemory),
|
|
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
|
|
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
|
|
Scenes = scenes
|
|
};
|
|
|
|
StabiliseCharacterIllustrations(model);
|
|
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
|
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, 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;
|
|
yield return new StoryIntelligenceExperienceSceneState
|
|
{
|
|
Id = $"scene-result-{item.Result.SceneResultID}",
|
|
IsCurrent = isCurrent,
|
|
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(storyMemory.ResolvePointOfView(scene)),
|
|
Location = BuildLocation(scene),
|
|
Characters = BuildCharacters(scene, storyMemory),
|
|
Assets = BuildAssets(scene),
|
|
Relationships = BuildRelationships(scene, storyMemory),
|
|
KnowledgeThreads = BuildKnowledge(scene),
|
|
Observations = BuildObservations(scene),
|
|
LatestDiscoveries = isCurrent ? BuildDiscoveries(scene).ToList() : [],
|
|
ActivityFeed = isCurrent ? BuildActivity(run, sceneNumber).ToList() : []
|
|
};
|
|
}
|
|
}
|
|
|
|
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);
|
|
var sceneCharacterNames = new HashSet<string>((scene.Characters ?? []).Select(character => Clean(character.Name)), 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" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene 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)
|
|
});
|
|
}
|
|
|
|
if (storyMemory is not null)
|
|
{
|
|
foreach (var memoryCharacter in storyMemory.Characters
|
|
.Where(character => !sceneCharacterNames.Contains(character.Name))
|
|
.OrderByDescending(character => character.Weight)
|
|
.Take(Math.Max(0, 5 - states.Count)))
|
|
{
|
|
var libraryCode = CharacterLibraryCode(memoryCharacter.Name, null, scene, reservedCodes);
|
|
reservedCodes.Add(libraryCode);
|
|
states.Add(new StoryIntelligenceExperienceCharacterState
|
|
{
|
|
Id = CharacterId(memoryCharacter.Name),
|
|
LibraryCode = libraryCode,
|
|
Name = memoryCharacter.Name,
|
|
Role = memoryCharacter.Role,
|
|
Relevance = memoryCharacter.Relevance,
|
|
Weight = Math.Max(38, memoryCharacter.Weight - 18),
|
|
ImagePath = CharacterFallback(memoryCharacter.Name)
|
|
});
|
|
}
|
|
}
|
|
|
|
return states
|
|
.OrderByDescending(character => character.Weight)
|
|
.GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase)
|
|
.Select(group => group.First())
|
|
.ToList();
|
|
}
|
|
|
|
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)
|
|
=> (scene.Observations ?? []).Select(observation => new StoryIntelligenceExperienceTextItem
|
|
{
|
|
Id = $"observation-result-{StableKey(observation.ObservationType)}-{StableKey(observation.SubjectName)}-{StableKey(observation.Predicate)}",
|
|
Title = Trim(FirstConfigured(observation.Description, observation.Predicate, observation.ObservationType, "Story observation"), 74),
|
|
Detail = Trim(FirstConfigured(observation.Evidence, $"{observation.SubjectName} {observation.Predicate} {observation.ObjectName}".Trim(), "Observed in current scene"), 128),
|
|
Tone = "insight"
|
|
})
|
|
.Take(4)
|
|
.ToList();
|
|
|
|
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)
|
|
?? bookNarratorName
|
|
?? narratorResolvedName;
|
|
ResolvedPovIdentity = resolved ?? raw;
|
|
PovResolutionReason = resolved is null ? "unresolved narrator" : "inherited resolved 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)
|
|
?? bookNarratorName
|
|
?? narratorResolvedName;
|
|
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)
|
|
?? bookNarratorName
|
|
?? narratorResolvedName
|
|
?? 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 "beth" or "rosie" or "maggie" or "grace" or "annie" or "anna" or "anne" or "helen" or "elen")
|
|
{
|
|
return "Feminine";
|
|
}
|
|
|
|
if (key is "graham" or "simon" or "kevin" or "colin" or "john" or "david")
|
|
{
|
|
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)
|
|
{
|
|
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.";
|
|
return new()
|
|
{
|
|
RequestedMode = "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 = displayedProgressReason,
|
|
ProgressSource = "ImportSession",
|
|
CurrentBackendStage = 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 = "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 = warning
|
|
};
|
|
}
|
|
|
|
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 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";
|
|
}
|
|
|
|
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 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 is "Bathroom" or "WaitingRoom" or "Road") return string.Empty;
|
|
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") return "asset-folded-letter";
|
|
if (type is "Notebook" or "Book") return "asset-worn-notebook";
|
|
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 == "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",
|
|
_ => $"{FallbackRoot}/location-flat.svg"
|
|
};
|
|
|
|
private static string AssetFallback(string name)
|
|
=> AssetLibraryCode(name, new SceneIntelligenceAsset()) switch
|
|
{
|
|
"asset-folded-letter" => $"{FallbackRoot}/asset-letter.svg",
|
|
"asset-worn-notebook" => $"{FallbackRoot}/asset-notebook.svg",
|
|
"asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg",
|
|
"asset-old-keys" => $"{FallbackRoot}/asset-keys.svg",
|
|
"asset-phone-recorder" => $"{FallbackRoot}/asset-phone.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 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"])
|
|
];
|
|
}
|