Phase 21F - Real Story Intelligence Snapshot Integration
This commit is contained in:
parent
1f984c8e1e
commit
88315070a2
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
})();
|
||||
|
||||
@ -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.
|
||||
Loading…
x
Reference in New Issue
Block a user