PlotDirector/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs

566 lines
27 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<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId);
}
public sealed class StoryIntelligenceVisualisationSnapshotService(
IStoryIntelligenceResultRepository repository,
IIllustrationLibraryService illustrationLibrary,
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
{
private const string FallbackRoot = "/images/story-intelligence/prototype";
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<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))
.OrderBy(result => result.TemporarySceneNumber)
.ThenBy(result => result.SceneResultID)
.ToList();
var parsed = results
.Select(result => new ParsedSceneResult(result, TryReadScene(result)))
.ToList();
var completed = parsed
.Where(item => item.Scene is not null && item.Result.ValidationErrorsCount == 0)
.ToList();
var current = PickCurrentScene(run, completed);
var scenes = BuildSceneStates(run, parsed, completed, current).ToList();
if (scenes.Count == 0)
{
scenes.Add(BuildEmptyScene(run));
}
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "real",
RunId = run.StoryIntelligenceRunID,
RunStatus = run.Status,
GeneratedUtc = DateTime.UtcNow,
ChangeToken = ChangeToken(run, results),
IsTerminal = IsTerminal(run.Status),
SnapshotMessage = SnapshotMessage(run, completed.Count),
ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"),
BookTitle = DisplayTitle(run.BookTitle, "Untitled book"),
Scenes = scenes
};
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", runId, stopwatch.ElapsedMilliseconds);
return model;
}
private static IEnumerable<StoryIntelligenceExperienceSceneState> BuildSceneStates(
StoryIntelligenceSavedRun run,
IReadOnlyList<ParsedSceneResult> allResults,
IReadOnlyList<ParsedSceneResult> completed,
ParsedSceneResult? current)
{
var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count);
var completedScenes = run.CompletedScenes ?? completed.Count;
foreach (var item in completed)
{
var scene = item.Scene!;
var sceneNumber = SceneNumber(item);
var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID;
yield return new StoryIntelligenceExperienceSceneState
{
Id = $"scene-result-{item.Result.SceneResultID}",
SceneNumber = sceneNumber,
Title = SceneTitle(scene, item.Result, sceneNumber),
Summary = Trim(FirstConfigured(scene.Summary?.Short, scene.Summary?.Detailed, run.CurrentMessage, "Scene analysed."), 180),
Stage = StageDisplay(run),
TimelineLabel = TimelineLabel(scene, item.Result),
TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42),
ProgressPercent = Progress(run, completedScenes, totalScenes),
ChaptersAnalysed = ChapterCount(completed),
ChapterTotal = Math.Max(1, run.SourceChapterCount ?? ChapterCount(completed)),
ScenesAnalysed = completedScenes,
SceneTotal = Math.Max(1, totalScenes),
EstimatedRemaining = EstimateRemaining(run, completedScenes, totalScenes),
PovCharacterId = CharacterId(scene.PointOfView?.CharacterName),
Location = BuildLocation(scene),
Characters = BuildCharacters(scene),
Assets = BuildAssets(scene),
Relationships = BuildRelationships(scene),
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",
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),
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.LastOrDefault(item => SceneNumber(item) <= (run.CompletedScenes ?? int.MaxValue)) ?? completed.Last();
}
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene)
{
var povName = Clean(scene.PointOfView?.CharacterName);
var states = new List<StoryIntelligenceExperienceCharacterState>();
foreach (var group in (scene.Characters ?? [])
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
.GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase)
.Take(5))
{
var character = group.OrderByDescending(item => item.Confidence ?? 0).First();
var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase);
states.Add(new StoryIntelligenceExperienceCharacterState
{
Id = CharacterId(group.Key),
LibraryCode = CharacterLibraryCode(group.Key, character),
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)))
{
states.Insert(0, new StoryIntelligenceExperienceCharacterState
{
Id = CharacterId(povName),
LibraryCode = CharacterLibraryCode(povName, null),
Name = povName,
Role = "POV character",
Relevance = "Point of view",
Weight = 100,
ImagePath = CharacterFallback(povName)
});
}
return states.OrderByDescending(character => character.Weight).ToList();
}
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)
=> (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)
.Take(4)
.ToList();
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 static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
{
return direct;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> results)
{
var raw = $"{run.StoryIntelligenceRunID}|{run.Status}|{run.UpdatedUtc:O}|{results.Count}|{results.Select(item => item.SceneResultID).DefaultIfEmpty().Max()}";
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
}
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 bool IsTerminal(string status)
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;
private static int SceneNumber(ParsedSceneResult item)
=> item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber;
private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber)
=> FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}");
private static string TimelineLabel(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result)
=> FirstConfigured(scene.TimelineClues?.FirstOrDefault()?.AbsoluteDate, scene.Setting?.DateOrTimeReference, result.SourceLabel, "Known scene");
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 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 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 = "loc-domestic-desk",
Name = "Location not yet identified",
Label = "Awaiting scene location",
ImagePath = $"{FallbackRoot}/location-flat.svg"
};
private static string CharacterId(string? name) => $"character-result-{StableKey(name)}";
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)
{
var value = $"{name} {character?.RoleInScene} {character?.Notes}".ToLowerInvariant();
if (value.Contains("red") || value.Contains("auburn") || value.Contains("witness")) return "char-young-adult-red-haired-witness";
if (value.Contains("older") || value.Contains("mother") || value.Contains("remember")) return "char-older-soft-presence";
if (value.Contains("suspect") || value.Contains("threat") || value.Contains("man")) return "char-middle-aged-weathered-man";
return "char-young-adult-brunette-observer";
}
private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting)
{
var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
if (value.Contains("laundry") || value.Contains("utility")) return "loc-laundry-utility-room";
if (value.Contains("stair")) return "loc-narrow-stairwell";
if (value.Contains("car park") || value.Contains("parking")) return "loc-wet-car-park";
if (value.Contains("flat") || value.Contains("apartment")) return "loc-small-flat-interior";
if (value.Contains("house") || value.Contains("estate")) return "loc-estate-house-exterior";
return "loc-domestic-desk";
}
private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset)
{
var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant();
if (value.Contains("letter")) return "asset-folded-letter";
if (value.Contains("notebook") || value.Contains("diary")) return "asset-worn-notebook";
if (value.Contains("car") || value.Contains("vehicle")) return "asset-red-classic-car";
if (value.Contains("key")) return "asset-old-keys";
if (value.Contains("phone") || value.Contains("record")) return "asset-phone-recorder";
if (value.Contains("photo")) return "asset-photo-print";
if (value.Contains("map")) return "asset-map-pin";
return "asset-locked-box";
}
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",
_ => $"{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-letter.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 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 ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene);
}