Phase 21F - Real Story Intelligence Snapshot Integration

This commit is contained in:
Nick Beckley 2026-07-12 15:09:26 +00:00
parent 1f984c8e1e
commit 88315070a2
10 changed files with 937 additions and 9 deletions

View File

@ -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);

View File

@ -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<IActionResult> StoryIntelligenceExperience()
public async Task<IActionResult> 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);
}
}

View File

@ -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<IActionResult> 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);
}
}

View File

@ -213,6 +213,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -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<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);
}

View File

@ -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<StoryIntelligenceExperienceRunOption> AvailableRuns { get; init; } = [];
public string ProjectName { get; init; } = string.Empty;
public string BookTitle { get; init; } = string.Empty;
public IReadOnlyList<StoryIntelligenceExperienceSceneState> 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;

View File

@ -15,7 +15,14 @@
<body>
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script>
<main class="story-exp" data-story-experience data-image-diagnostics="true">
<main class="story-exp"
data-story-experience
data-image-diagnostics="true"
data-mode="@Model.Mode"
data-run-id="@Model.RunId"
data-snapshot-url="@(Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)"
data-change-token="@Model.ChangeToken"
data-terminal="@Model.IsTerminal.ToString().ToLowerInvariant()">
<header class="story-exp-header" aria-label="Story Intelligence status">
<div class="story-exp-brand">
<strong>PlotDirector</strong>
@ -28,9 +35,20 @@
<span data-stage-label>Preparing</span>
<span>Scene <strong data-current-scene>1</strong></span>
</div>
<button class="story-exp-background" type="button">Continue in Background</button>
<a class="story-exp-background" href="/Development/StoryIntelligenceExperience?mode=simulation">Simulation</a>
</header>
@if (Model.AvailableRuns.Count > 0)
{
<nav class="story-exp-run-selector" aria-label="Development Story Intelligence runs">
<strong>Real Story Intelligence runs</strong>
@foreach (var run in Model.AvailableRuns)
{
<a href="/Development/StoryIntelligenceExperience?runId=@run.RunId">@run.Label</a>
}
</nav>
}
<section class="story-exp-shell" aria-label="Story Intelligence visual prototype">
<aside class="story-exp-left" aria-label="Analysis progress">
<div class="story-exp-progress">
@ -128,7 +146,7 @@
<aside class="story-exp-controls" aria-label="Prototype controls">
<div>
<span>Prototype controls</span>
<small>Simulation only</small>
<small>@(Model.Mode == "real" ? "Real snapshot" : "Simulation only")</small>
</div>
<button type="button" data-control="restart">Restart</button>
<button type="button" data-control="previous">Previous</button>

View File

@ -282,6 +282,50 @@ input:focus-visible {
background: rgba(215, 168, 93, 0.2);
}
.story-exp-background {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.story-exp-run-selector {
position: fixed;
left: 50%;
top: 86px;
z-index: 20;
display: grid;
gap: 8px;
width: min(720px, calc(100vw - 48px));
max-height: min(560px, calc(100vh - 160px));
overflow: auto;
border: 1px solid rgba(151, 185, 218, 0.18);
border-radius: 16px;
padding: 14px;
background: rgba(4, 10, 20, 0.9);
box-shadow: var(--story-shadow);
backdrop-filter: blur(18px);
transform: translateX(-50%);
}
.story-exp-run-selector strong {
color: var(--story-text);
}
.story-exp-run-selector a {
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 10px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--story-soft);
text-decoration: none;
}
.story-exp-run-selector a:hover {
border-color: rgba(215, 168, 93, 0.42);
color: var(--story-text);
}
.story-exp-shell {
display: grid;
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr);

View File

@ -3,11 +3,13 @@
const dataNode = document.getElementById("storyExperienceData");
if (!root || !dataNode) return;
const model = JSON.parse(dataNode.textContent || "{}");
const scenes = model.scenes || [];
let model = JSON.parse(dataNode.textContent || "{}");
let scenes = model.scenes || [];
if (!scenes.length) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const realMode = root.dataset.mode === "real";
const snapshotUrl = root.dataset.snapshotUrl || "";
const failedImageUrls = new Set();
const preloadedImageUrls = new Set();
const state = {
@ -15,7 +17,11 @@
playing: true,
intervalMs: 6200,
timer: null,
drawRequest: null
drawRequest: null,
pollTimer: null,
polling: false,
changeToken: model.changeToken || root.dataset.changeToken || "",
terminal: model.isTerminal || root.dataset.terminal === "true"
};
const analysisStages = [
{ key: "reading", label: "Reading" },
@ -87,6 +93,24 @@
scheduleConnectionDraw();
}
function replaceModel(nextModel) {
model = nextModel || model;
scenes = model.scenes || [];
state.changeToken = model.changeToken || state.changeToken;
state.terminal = !!model.isTerminal;
state.index = Math.min(state.index, Math.max(0, scenes.length - 1));
if (model.projectName) {
const project = root.querySelector(".story-exp-context span:first-child");
text(project, model.projectName);
}
if (model.bookTitle) {
const book = root.querySelector(".story-exp-context span:nth-child(2)");
text(book, model.bookTitle);
}
transitionToNextState(state.index);
if (state.terminal) stopPolling();
}
function updateProgress(scene) {
text(dom.stageLabel, scene.stage);
text(dom.currentScene, scene.sceneNumber);
@ -949,7 +973,7 @@
function startTimer() {
stopTimer();
if (!state.playing) return;
if (!state.playing || realMode) return;
state.timer = window.setInterval(() => {
const next = state.index >= scenes.length - 1 ? 0 : state.index + 1;
transitionToNextState(next);
@ -963,6 +987,39 @@
}
}
function startPolling() {
if (!realMode || !snapshotUrl || state.terminal) return;
stopPolling();
state.pollTimer = window.setInterval(pollSnapshot, document.hidden ? 15000 : 7000);
}
function stopPolling() {
if (state.pollTimer) {
window.clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
async function pollSnapshot() {
if (state.polling || document.hidden || state.terminal) return;
state.polling = true;
try {
const response = await fetch(snapshotUrl, { headers: { Accept: "application/json" } });
if (!response.ok) return;
const snapshot = await response.json();
if (snapshot.changeToken && snapshot.changeToken !== state.changeToken) {
replaceModel(snapshot);
} else if (snapshot.isTerminal) {
state.terminal = true;
stopPolling();
}
} catch {
// Development snapshot polling is best-effort.
} finally {
state.polling = false;
}
}
function text(node, value) {
if (node) node.textContent = value ?? "";
}
@ -982,31 +1039,42 @@
}
root.querySelector("[data-control='restart']")?.addEventListener("click", () => {
if (realMode) return;
transitionToNextState(0);
startTimer();
});
root.querySelector("[data-control='previous']")?.addEventListener("click", () => {
if (realMode) return;
transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1);
startTimer();
});
root.querySelector("[data-control='next']")?.addEventListener("click", () => {
if (realMode) return;
transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1);
startTimer();
});
dom.play?.addEventListener("click", () => {
if (realMode) return;
state.playing = !state.playing;
text(dom.play, state.playing ? "Pause" : "Play");
if (state.playing) startTimer(); else stopTimer();
});
dom.speed?.addEventListener("input", () => {
if (realMode) return;
state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200;
startTimer();
});
document.addEventListener("visibilitychange", () => {
if (!realMode || state.terminal) return;
startPolling();
if (!document.hidden) pollSnapshot();
});
window.addEventListener("resize", () => {
const current = loadSceneState(state.index);
const layout = sceneLayout(current);
@ -1029,4 +1097,5 @@
transitionToNextState(0);
startTimer();
startPolling();
})();

View File

@ -0,0 +1,124 @@
# Phase 21F - Story Intelligence Visualisation Snapshot
## Snapshot Architecture
Phase 21F adds a read-only real-data snapshot path for the existing Story Intelligence Experience prototype. The browser contract remains the existing `StoryIntelligenceExperiencePrototypeViewModel` shape so the renderer can display either simulation data or persisted Story Intelligence run data.
## Data Sources
The snapshot service reads:
- `StoryIntelligenceSavedRun`
- `StoryIntelligenceSavedSceneResult`
- parsed `SceneIntelligenceScene` JSON
- existing Illustration Library resolution through `IIllustrationLibraryService.ApplyApprovedIllustrationsAsync`
It deliberately does not expose source manuscript text, raw AI responses, prompts, or import write operations.
## DTO Contract
The model adds snapshot metadata:
- `mode`
- `runId`
- `runStatus`
- `changeToken`
- `generatedUtc`
- `isTerminal`
- `snapshotMessage`
- `availableRuns`
The renderer continues to consume `scenes`, `characters`, `location`, `assets`, `relationships`, `knowledgeThreads`, `observations`, `latestDiscoveries`, and `activityFeed`.
## Stable Visual IDs
Stable IDs are deterministic:
- scenes: `scene-result-{SceneResultID}`
- waiting scene: `run-{RunID}-waiting`
- characters: `character-result-{normalised-name}`
- locations: `location-result-{normalised-name}`
- assets: `asset-result-{normalised-name}`
- relationships: `relationship-result-{character-a}-{character-b}-{signal}`
- knowledge: `knowledge-result-{recipient}-{knowledge-item}` or `question-result-{question}`
- observations: `observation-result-{type}-{subject}-{predicate}`
Normalised names are lower-case, alphanumeric slug keys.
## Current Scene Selection
The current scene is the latest valid parsed scene at or below the run's completed-scene count. If no parsed scenes exist, the snapshot returns a waiting scene so the visual shell remains intact.
## Character Rules
The current scene maps parsed characters and point-of-view data. Explicit POV receives weight `100`. Mentioned-only characters are lower weight. No POV is invented if the parsed data does not provide one.
## Location Rules
The snapshot chooses one active location, preferring present, specific, high-confidence locations. If none exists, it returns a graceful "Location not yet identified" placeholder.
## Asset Rules
The snapshot returns up to five significant current-scene assets. Mentioned-only and clearly low-value/background assets are filtered.
## Relationship Rules
Relationships are limited to the current scene parsed relationships. They use deterministic IDs and confidence-derived weights. The renderer may suppress inline labels if there is no clean visual space.
## Knowledge Mapping
Questions raised and knowledge changes feed the Knowledge Threads panel. Parsed observations feed the Story Observations panel. Wording is concise and user-facing.
## Illustration Resolution
The snapshot assigns starter Illustration Library stable codes using simple non-manuscript heuristics and then reuses the existing Illustration Library resolver. It does not generate images.
## API Route
`GET /api/story-intelligence/runs/{runId}/visualisation-snapshot`
The endpoint is authenticated and returns only runs owned by the signed-in user in this phase.
## Development Route
- Simulation: `/Development/StoryIntelligenceExperience?mode=simulation`
- Real run: `/Development/StoryIntelligenceExperience?runId={runId}`
- Selector: `/Development/StoryIntelligenceExperience`
The selector is development/admin-only.
## Refresh Mechanism
Real mode uses restrained polling of the snapshot endpoint. It polls about every 7 seconds while visible, backs off while hidden, avoids overlapping requests, and stops once the run is terminal.
## Completion Behaviour
Completed and completed-with-warning runs show the final provisional snapshot and stop polling. Failed and cancelled runs show the available snapshot plus a clear status message.
## Performance Notes
The service parses scene result JSON once per snapshot request, limits central collections, does not load manuscript source text, and logs snapshot build duration.
## Tests
Added coverage for:
- simulation mode remaining available
- real snapshot contract metadata
- absence of raw manuscript text, raw AI responses, and prompts from the visualisation contract
Existing build and Story Intelligence/Illustration tests continue to run.
## Known Gaps
- Collaborator/project access beyond run ownership is not yet expanded for this development endpoint.
- Canonical uploaded entity images are not resolved ahead of Illustration Library images yet.
- The route is not linked from the production Story Intelligence progress workflow.
- Relationship/knowledge mapping uses current scene parsed data, not a durable replay history.
## Recommended Phase 21G Work
- Broaden access checks to full project collaborator semantics.
- Resolve canonical entity images before library fallbacks where canonical entity matches are available.
- Connect the real Story Intelligence workflow to this visualisation at the appropriate handoff point.
- Add a lightweight status-token endpoint if polling full snapshots becomes too heavy.