566 lines
27 KiB
C#
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);
|
|
}
|