diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index fe5ed17..7ecf0d3 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using PlotLine.Models; using PlotLine.Services; +using PlotLine.ViewModels; var tests = new (string Name, Action Test)[] { @@ -34,6 +35,8 @@ var tests = new (string Name, Action Test)[] ("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts), ("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems), ("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes), + ("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable), + ("Story Intelligence visualisation contract omits raw manuscript text", StoryIntelligenceVisualisationContractOmitsRawManuscriptText), ("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts), ("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes), ("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel), @@ -450,6 +453,43 @@ static void StoryIntelligencePrototypeUsesStarterIllustrationCodes() Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution."); } +static void StoryIntelligenceSimulationModeRemainsAvailable() +{ + var prototype = StoryIntelligenceExperiencePrototypeData.Build(); + + Assert(prototype.Scenes.Count >= 7, "Simulation should keep the existing multi-scene visual dataset."); + Assert(prototype.Scenes.Any(scene => scene.PovCharacterId == "rosie"), "Simulation should still exercise POV transitions."); +} + +static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText() +{ + var model = new StoryIntelligenceExperiencePrototypeViewModel + { + Mode = "real", + RunId = 42, + ChangeToken = "stable-token", + ProjectName = "Project", + BookTitle = "Book", + Scenes = + [ + new StoryIntelligenceExperienceSceneState + { + Id = "scene-result-1", + SceneNumber = 1, + Title = "Analysed scene", + Summary = "Safe summary", + Location = new StoryIntelligenceExperienceLocationState { Id = "location-result-room", Name = "Room" } + } + ] + }; + + var json = JsonSerializer.Serialize(model, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + Assert(json.Contains("\"changeToken\":\"stable-token\"", StringComparison.Ordinal), "Snapshot contract should expose a change token."); + Assert(!json.Contains("sourceText", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw manuscript source text."); + Assert(!json.Contains("rawResponseJson", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw AI responses."); + Assert(!json.Contains("prompt", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose prompts."); +} + static void IllustrationBulkRetryResultReportsCounts() { var result = new IllustrationBulkRetryResult(12, 4, 3); diff --git a/PlotLine/Controllers/DevelopmentController.cs b/PlotLine/Controllers/DevelopmentController.cs index 990bdc6..ddbd40b 100644 --- a/PlotLine/Controllers/DevelopmentController.cs +++ b/PlotLine/Controllers/DevelopmentController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PlotLine.Services; +using PlotLine.ViewModels; namespace PlotLine.Controllers; @@ -8,18 +9,44 @@ namespace PlotLine.Controllers; [Route("Development")] public sealed class DevelopmentController( IWebHostEnvironment environment, - IIllustrationLibraryService illustrationLibrary) : Controller + ICurrentUserService currentUser, + IIllustrationLibraryService illustrationLibrary, + IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller { [HttpGet("StoryIntelligenceExperience")] - public async Task StoryIntelligenceExperience() + public async Task StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? runId) { if (!environment.IsDevelopment()) { return NotFound(); } + if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && runId.HasValue && currentUser.UserId is int userId) + { + var realModel = await snapshots.BuildSnapshotAsync(runId.Value, userId); + if (realModel is null) + { + return NotFound(); + } + + return View(realModel); + } + var model = StoryIntelligenceExperiencePrototypeData.Build(); await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); + if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && currentUser.UserId is int selectorUserId) + { + model = new StoryIntelligenceExperiencePrototypeViewModel + { + Mode = "selector", + AvailableRuns = await snapshots.ListRunOptionsAsync(selectorUserId), + ProjectName = model.ProjectName, + BookTitle = model.BookTitle, + Scenes = model.Scenes, + SnapshotMessage = "Choose a persisted Story Intelligence run, or open simulation mode." + }; + } + return View(model); } } diff --git a/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs b/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs new file mode 100644 index 0000000..d860784 --- /dev/null +++ b/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PlotLine.Services; + +namespace PlotLine.Controllers; + +[Authorize] +[Route("api/story-intelligence")] +public sealed class StoryIntelligenceVisualisationController( + ICurrentUserService currentUser, + IStoryIntelligenceVisualisationSnapshotService snapshots) : ControllerBase +{ + [HttpGet("runs/{runId:int}/visualisation-snapshot")] + public async Task GetSnapshot(int runId) + { + if (currentUser.UserId is not int userId) + { + return Unauthorized(); + } + + var snapshot = await snapshots.BuildSnapshotAsync(runId, userId); + return snapshot is null ? NotFound() : Ok(snapshot); + } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index c54bfec..837b29f 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -213,6 +213,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs new file mode 100644 index 0000000..d6a7632 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -0,0 +1,565 @@ +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 BuildSnapshotAsync(int runId, int userId); + Task> ListRunOptionsAsync(int userId); +} + +public sealed class StoryIntelligenceVisualisationSnapshotService( + IStoryIntelligenceResultRepository repository, + IIllustrationLibraryService illustrationLibrary, + ILogger logger) : IStoryIntelligenceVisualisationSnapshotService +{ + private const string FallbackRoot = "/images/story-intelligence/prototype"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public async Task> 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 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 BuildSceneStates( + StoryIntelligenceSavedRun run, + IReadOnlyList allResults, + IReadOnlyList 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 completed) + { + if (completed.Count == 0) + { + return null; + } + + return completed.LastOrDefault(item => SceneNumber(item) <= (run.CompletedScenes ?? int.MaxValue)) ?? completed.Last(); + } + + private static IReadOnlyList BuildCharacters(SceneIntelligenceScene scene) + { + var povName = Clean(scene.PointOfView?.CharacterName); + var states = new List(); + 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 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 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 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 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 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 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(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(JsonOptions); + } + } + catch (JsonException) + { + } + + return null; + } + + private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList 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 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); +} diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index 7c18127..687d045 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -2,11 +2,27 @@ namespace PlotLine.ViewModels; public sealed class StoryIntelligenceExperiencePrototypeViewModel { + public string Mode { get; init; } = "simulation"; + public int? RunId { get; init; } + public string? RunStatus { get; init; } + public string? ChangeToken { get; init; } + public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow; + public bool IsTerminal { get; init; } + public string? SnapshotMessage { get; init; } + public IReadOnlyList AvailableRuns { get; init; } = []; public string ProjectName { get; init; } = string.Empty; public string BookTitle { get; init; } = string.Empty; public IReadOnlyList Scenes { get; init; } = []; } +public sealed class StoryIntelligenceExperienceRunOption +{ + public int RunId { get; init; } + public string Label { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public DateTime CreatedUtc { get; init; } +} + public sealed class StoryIntelligenceExperienceSceneState { public string Id { get; init; } = string.Empty; diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 58f6cc4..7d8cb0d 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -15,7 +15,14 @@ -
+
PlotDirector @@ -28,9 +35,20 @@ Preparing Scene 1
- + Simulation
+ @if (Model.AvailableRuns.Count > 0) + { + + } +