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 Microsoft.Extensions.Options;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
var tests = new (string Name, Action Test)[]
|
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 batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
|
||||||
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
|
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
|
||||||
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
|
("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 result reports counts", IllustrationBulkRetryResultReportsCounts),
|
||||||
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
|
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
|
||||||
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
|
("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.");
|
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()
|
static void IllustrationBulkRetryResultReportsCounts()
|
||||||
{
|
{
|
||||||
var result = new IllustrationBulkRetryResult(12, 4, 3);
|
var result = new IllustrationBulkRetryResult(12, 4, 3);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
namespace PlotLine.Controllers;
|
namespace PlotLine.Controllers;
|
||||||
|
|
||||||
@ -8,18 +9,44 @@ namespace PlotLine.Controllers;
|
|||||||
[Route("Development")]
|
[Route("Development")]
|
||||||
public sealed class DevelopmentController(
|
public sealed class DevelopmentController(
|
||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
IIllustrationLibraryService illustrationLibrary) : Controller
|
ICurrentUserService currentUser,
|
||||||
|
IIllustrationLibraryService illustrationLibrary,
|
||||||
|
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("StoryIntelligenceExperience")]
|
[HttpGet("StoryIntelligenceExperience")]
|
||||||
public async Task<IActionResult> StoryIntelligenceExperience()
|
public async Task<IActionResult> StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? runId)
|
||||||
{
|
{
|
||||||
if (!environment.IsDevelopment())
|
if (!environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
return NotFound();
|
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();
|
var model = StoryIntelligenceExperiencePrototypeData.Build();
|
||||||
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
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);
|
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<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
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 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 ProjectName { get; init; } = string.Empty;
|
||||||
public string BookTitle { get; init; } = string.Empty;
|
public string BookTitle { get; init; } = string.Empty;
|
||||||
public IReadOnlyList<StoryIntelligenceExperienceSceneState> Scenes { get; init; } = [];
|
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 sealed class StoryIntelligenceExperienceSceneState
|
||||||
{
|
{
|
||||||
public string Id { get; init; } = string.Empty;
|
public string Id { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -15,7 +15,14 @@
|
|||||||
<body>
|
<body>
|
||||||
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script>
|
<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">
|
<header class="story-exp-header" aria-label="Story Intelligence status">
|
||||||
<div class="story-exp-brand">
|
<div class="story-exp-brand">
|
||||||
<strong>PlotDirector</strong>
|
<strong>PlotDirector</strong>
|
||||||
@ -28,9 +35,20 @@
|
|||||||
<span data-stage-label>Preparing</span>
|
<span data-stage-label>Preparing</span>
|
||||||
<span>Scene <strong data-current-scene>1</strong></span>
|
<span>Scene <strong data-current-scene>1</strong></span>
|
||||||
</div>
|
</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>
|
</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">
|
<section class="story-exp-shell" aria-label="Story Intelligence visual prototype">
|
||||||
<aside class="story-exp-left" aria-label="Analysis progress">
|
<aside class="story-exp-left" aria-label="Analysis progress">
|
||||||
<div class="story-exp-progress">
|
<div class="story-exp-progress">
|
||||||
@ -128,7 +146,7 @@
|
|||||||
<aside class="story-exp-controls" aria-label="Prototype controls">
|
<aside class="story-exp-controls" aria-label="Prototype controls">
|
||||||
<div>
|
<div>
|
||||||
<span>Prototype controls</span>
|
<span>Prototype controls</span>
|
||||||
<small>Simulation only</small>
|
<small>@(Model.Mode == "real" ? "Real snapshot" : "Simulation only")</small>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-control="restart">Restart</button>
|
<button type="button" data-control="restart">Restart</button>
|
||||||
<button type="button" data-control="previous">Previous</button>
|
<button type="button" data-control="previous">Previous</button>
|
||||||
|
|||||||
@ -282,6 +282,50 @@ input:focus-visible {
|
|||||||
background: rgba(215, 168, 93, 0.2);
|
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 {
|
.story-exp-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr);
|
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr);
|
||||||
|
|||||||
@ -3,11 +3,13 @@
|
|||||||
const dataNode = document.getElementById("storyExperienceData");
|
const dataNode = document.getElementById("storyExperienceData");
|
||||||
if (!root || !dataNode) return;
|
if (!root || !dataNode) return;
|
||||||
|
|
||||||
const model = JSON.parse(dataNode.textContent || "{}");
|
let model = JSON.parse(dataNode.textContent || "{}");
|
||||||
const scenes = model.scenes || [];
|
let scenes = model.scenes || [];
|
||||||
if (!scenes.length) return;
|
if (!scenes.length) return;
|
||||||
|
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
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 failedImageUrls = new Set();
|
||||||
const preloadedImageUrls = new Set();
|
const preloadedImageUrls = new Set();
|
||||||
const state = {
|
const state = {
|
||||||
@ -15,7 +17,11 @@
|
|||||||
playing: true,
|
playing: true,
|
||||||
intervalMs: 6200,
|
intervalMs: 6200,
|
||||||
timer: null,
|
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 = [
|
const analysisStages = [
|
||||||
{ key: "reading", label: "Reading" },
|
{ key: "reading", label: "Reading" },
|
||||||
@ -87,6 +93,24 @@
|
|||||||
scheduleConnectionDraw();
|
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) {
|
function updateProgress(scene) {
|
||||||
text(dom.stageLabel, scene.stage);
|
text(dom.stageLabel, scene.stage);
|
||||||
text(dom.currentScene, scene.sceneNumber);
|
text(dom.currentScene, scene.sceneNumber);
|
||||||
@ -949,7 +973,7 @@
|
|||||||
|
|
||||||
function startTimer() {
|
function startTimer() {
|
||||||
stopTimer();
|
stopTimer();
|
||||||
if (!state.playing) return;
|
if (!state.playing || realMode) return;
|
||||||
state.timer = window.setInterval(() => {
|
state.timer = window.setInterval(() => {
|
||||||
const next = state.index >= scenes.length - 1 ? 0 : state.index + 1;
|
const next = state.index >= scenes.length - 1 ? 0 : state.index + 1;
|
||||||
transitionToNextState(next);
|
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) {
|
function text(node, value) {
|
||||||
if (node) node.textContent = value ?? "";
|
if (node) node.textContent = value ?? "";
|
||||||
}
|
}
|
||||||
@ -982,31 +1039,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
root.querySelector("[data-control='restart']")?.addEventListener("click", () => {
|
root.querySelector("[data-control='restart']")?.addEventListener("click", () => {
|
||||||
|
if (realMode) return;
|
||||||
transitionToNextState(0);
|
transitionToNextState(0);
|
||||||
startTimer();
|
startTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
root.querySelector("[data-control='previous']")?.addEventListener("click", () => {
|
root.querySelector("[data-control='previous']")?.addEventListener("click", () => {
|
||||||
|
if (realMode) return;
|
||||||
transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1);
|
transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1);
|
||||||
startTimer();
|
startTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
root.querySelector("[data-control='next']")?.addEventListener("click", () => {
|
root.querySelector("[data-control='next']")?.addEventListener("click", () => {
|
||||||
|
if (realMode) return;
|
||||||
transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1);
|
transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1);
|
||||||
startTimer();
|
startTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
dom.play?.addEventListener("click", () => {
|
dom.play?.addEventListener("click", () => {
|
||||||
|
if (realMode) return;
|
||||||
state.playing = !state.playing;
|
state.playing = !state.playing;
|
||||||
text(dom.play, state.playing ? "Pause" : "Play");
|
text(dom.play, state.playing ? "Pause" : "Play");
|
||||||
if (state.playing) startTimer(); else stopTimer();
|
if (state.playing) startTimer(); else stopTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
dom.speed?.addEventListener("input", () => {
|
dom.speed?.addEventListener("input", () => {
|
||||||
|
if (realMode) return;
|
||||||
state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200;
|
state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200;
|
||||||
startTimer();
|
startTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!realMode || state.terminal) return;
|
||||||
|
startPolling();
|
||||||
|
if (!document.hidden) pollSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
const current = loadSceneState(state.index);
|
const current = loadSceneState(state.index);
|
||||||
const layout = sceneLayout(current);
|
const layout = sceneLayout(current);
|
||||||
@ -1029,4 +1097,5 @@
|
|||||||
|
|
||||||
transitionToNextState(0);
|
transitionToNextState(0);
|
||||||
startTimer();
|
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