Remove Story Intelligence visualisation runtime

This commit is contained in:
Nick Beckley 2026-08-24 14:47:29 +00:00
parent 25a8ce65fa
commit 2bb5decf63
67 changed files with 773 additions and 4358 deletions

View File

@ -10,6 +10,8 @@ using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels; using PlotLine.ViewModels;
#pragma warning disable CS8321
var tests = new (string Name, Action Test)[] var tests = new (string Name, Action Test)[]
{ {
("Malformed confidence 0. is repaired to 0.0", RepairsTrailingDecimalConfidence), ("Malformed confidence 0. is repaired to 0.0", RepairsTrailingDecimalConfidence),
@ -43,10 +45,9 @@ var tests = new (string Name, Action Test)[]
("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely), ("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely),
("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 visualisation archive is preserved", StoryIntelligenceVisualisationArchiveIsPreserved),
("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable), ("Story Intelligence visualisation runtime is removed", StoryIntelligenceVisualisationRuntimeIsRemoved),
("Story Intelligence does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota), ("Story Intelligence does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota),
("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),
@ -59,17 +60,9 @@ var tests = new (string Name, Action Test)[]
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate), ("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage), ("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes), ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity), ("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress),
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions),
("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden),
("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding), ("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable), ("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates), ("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords), ("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords),
@ -82,9 +75,6 @@ var tests = new (string Name, Action Test)[]
("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures), ("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired), ("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence),
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions), ("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation), ("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation),
("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity), ("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity),
@ -957,21 +947,19 @@ static void IllustrationStarterGenerationPlanSkipsSuccessfulItems()
Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible."); Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible.");
} }
static void StoryIntelligencePrototypeUsesStarterIllustrationCodes() static void StoryIntelligenceVisualisationArchiveIsPreserved()
{ {
var prototype = StoryIntelligenceExperiencePrototypeData.Build(); var root = Path.Combine(AppContext.BaseDirectory, "../../../../");
var codes = prototype.Scenes var archive = Path.Combine(root, "docs/story-intelligence/import-visualisation-archive.md");
.SelectMany(scene => scene.Characters.Select(character => character.LibraryCode) var archiveSource = Path.Combine(root, "docs/story-intelligence/visualisation-archive/runtime-source");
.Concat([scene.Location.LibraryCode])
.Concat(scene.Assets.Select(asset => asset.LibraryCode)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
Assert(codes.Count > 0, "Prototype should carry explicit library codes."); Assert(File.Exists(archive), "Visualisation archive document should be preserved.");
Assert(codes.All(code => IllustrationStarterBatchDefinition.StarterCodes.Contains(code)), "Prototype requested a stable code that is not in the starter library."); var text = File.ReadAllText(archive);
Assert(prototype.Scenes.SelectMany(scene => scene.Characters).All(character => character.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Character SVG fallback should remain before library resolution."); Assert(text.Contains("must never cause additional analysis or AI work", StringComparison.OrdinalIgnoreCase), "Archive must record the passive-observer architectural lesson.");
Assert(prototype.Scenes.All(scene => scene.Location.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Location SVG fallback should remain before library resolution."); Assert(File.Exists(Path.Combine(archiveSource, "StoryIntelligenceExperience.cshtml")), "Archived Razor markup should be preserved outside runtime.");
Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution."); Assert(File.Exists(Path.Combine(archiveSource, "story-intelligence-experience-prototype.css")), "Archived CSS should be preserved outside runtime.");
Assert(File.Exists(Path.Combine(archiveSource, "story-intelligence-experience-prototype.js")), "Archived JavaScript should be preserved outside runtime.");
Assert(Directory.Exists(Path.Combine(archiveSource, "prototype-assets")), "Archived prototype assets should be preserved outside runtime.");
} }
static void IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth() static void IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth()
@ -1187,12 +1175,32 @@ static StoryMemoryCharacterAttribute Attribute(string type, string value, decima
static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata) static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata)
=> StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false); => StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false);
static void StoryIntelligenceSimulationModeRemainsAvailable() static void StoryIntelligenceVisualisationRuntimeIsRemoved()
{ {
var prototype = StoryIntelligenceExperiencePrototypeData.Build(); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
Assert(!File.Exists(Path.Combine(root, "Controllers/DevelopmentController.cs")), "Development visualisation controller should not be active.");
Assert(!File.Exists(Path.Combine(root, "Controllers/StoryIntelligenceVisualisationController.cs")), "Visualisation snapshot API controller should not be active.");
Assert(!File.Exists(Path.Combine(root, "Views/Development/StoryIntelligenceExperience.cshtml")), "Visualisation Razor view should not be active.");
Assert(!File.Exists(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js")), "Visualisation JavaScript should not be served from runtime.");
Assert(!File.Exists(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css")), "Visualisation CSS should not be served from runtime.");
Assert(!Directory.Exists(Path.Combine(root, "wwwroot/images/story-intelligence/prototype")), "Prototype SVG assets should not be served from runtime.");
Assert(prototype.Scenes.Count >= 7, "Simulation should keep the existing multi-scene visual dataset."); var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
Assert(prototype.Scenes.Any(scene => scene.PovCharacterId == "rosie"), "Simulation should still exercise POV transitions."); Assert(!program.Contains("IStoryIntelligenceVisualisationSnapshotService", StringComparison.Ordinal), "Visualisation snapshot service should not be registered.");
Assert(!program.Contains("IStoryIntelligenceIllustrationMatchingService", StringComparison.Ordinal), "Visualisation illustration matching adapter should not be registered.");
}
static void LeanImportRemovesReplayLinksWhileRetainingProgress()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
var progressScript = Path.Combine(root, "wwwroot/js/story-intelligence-progress.js");
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
Assert(!progressView.Contains("Replay Story Intelligence", StringComparison.Ordinal), "Ordinary import progress should not link to replay visualisation.");
Assert(!progressView.Contains("/Development/StoryIntelligenceExperience", StringComparison.Ordinal), "Ordinary import progress should not expose the development visualisation route.");
Assert(File.Exists(progressScript), "Lean import should retain the operational progress script.");
Assert(program.Contains("app.MapHub<StoryIntelligenceHub>", StringComparison.Ordinal), "Lean import should retain the shared Story Intelligence progress hub.");
} }
static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota() static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota()
@ -1205,31 +1213,7 @@ static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota()
static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText() static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText()
{ {
var model = new StoryIntelligenceExperiencePrototypeViewModel Assert(true, "Historical visualisation contract helper retained only to minimise churn after visualisation removal.");
{
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()
@ -1459,42 +1443,7 @@ static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
static void LiveVisualisationStripsIllustrationDiagnostics() static void LiveVisualisationStripsIllustrationDiagnostics()
{ {
var large = new string('x', 200_000); Assert(true, "Historical helper retained only to minimise churn after visualisation removal.");
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
CharacterConsistencyReport = large
},
Scenes =
[
new StoryIntelligenceExperienceSceneState
{
Id = "scene-result-1",
Location = new StoryIntelligenceExperienceLocationState(),
Characters =
[
new StoryIntelligenceExperienceCharacterState
{
Id = "character-result-beth",
Name = "Beth",
ImageResolution = new StoryIntelligenceExperienceImageResolution
{
RejectedCandidates = large,
PreviousEvidence = large,
CurrentEvidence = large,
EvidenceWarnings = large
}
}
]
}
]
};
StoryIntelligenceVisualisationSnapshotService.StripLiveDiagnostics(model);
var json = JsonSerializer.Serialize(model, JsonOptions());
Assert(!json.Contains(large, StringComparison.Ordinal), "Live snapshot should not contain large illustration diagnostic strings.");
Assert(json.Length < 20_000, $"Live snapshot remained too large after stripping diagnostics: {json.Length:N0} chars.");
} }
static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence() static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence()
@ -1633,11 +1582,9 @@ static void Phase21TRepairsDurableCharacterUnderstanding()
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("pair of girls"), "Pair of girls should not reserve a unique character portrait."); Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("pair of girls"), "Pair of girls should not reserve a unique character portrait.");
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs")); var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis."); Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis.");
Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence."); Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence.");
Assert(!snapshot.Contains("memoryCharacter", StringComparison.Ordinal), "Visualisation must not carry memory-only characters into scenes without scene evidence."); Assert(!File.Exists(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")), "Visualisation snapshot service should stay removed.");
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Live and replay must apply durable Story Memory assignments.");
} }
static void Phase21UExtractsMandatoryCharacterAppearanceFixtures() static void Phase21UExtractsMandatoryCharacterAppearanceFixtures()

View File

@ -1,193 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize(Policy = "AdminOnly")]
[Route("Development")]
public sealed class DevelopmentController(
IWebHostEnvironment environment,
ICurrentUserService currentUser,
IIllustrationLibraryService illustrationLibrary,
IStoryMemoryService storyMemory,
IStoryMemoryRepository storyMemoryRepository,
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
{
[HttpGet("StoryIntelligenceExperience")]
public async Task<IActionResult> StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? importSessionId, [FromQuery] int? runId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase);
var replayRequested = string.Equals(requestedMode, "replay", StringComparison.OrdinalIgnoreCase);
if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId)
{
var realModel = replayRequested
? await snapshots.BuildReplaySnapshotAsync(importSessionId.Value, userId)
: await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId);
if (realModel is null)
{
return View(ErrorModel(
requestedMode,
importSessionId,
$"Import Session {importSessionId.Value:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet."));
}
return View(realModel);
}
if (!simulationRequested && runId.HasValue && currentUser.UserId is int legacyUserId)
{
var realModel = await snapshots.BuildSnapshotAsync(runId.Value, legacyUserId);
if (realModel is null)
{
return View(ErrorModel(
requestedMode,
null,
$"Run {runId.Value:N0} could not be found for the current user. Live visualisation URLs should use importSessionId for book imports."));
}
return View(realModel);
}
if (simulationRequested)
{
var model = StoryIntelligenceExperiencePrototypeData.Build();
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
return View(model);
}
if (currentUser.UserId is int selectorUserId)
{
var sessions = await snapshots.ListImportSessionOptionsAsync(selectorUserId);
return View(new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "selector",
AvailableRuns = sessions,
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = sessions.Count == 0
? "No persisted Story Intelligence import sessions were found for the current user. Start a real import, then refresh this page."
: "Choose a Story Intelligence import session, or explicitly open simulation mode.",
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real"
}
});
}
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
}
[HttpPost("StoryIntelligenceExperience/Rebuild")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RebuildStoryIntelligenceExperience(
[FromForm] int importSessionId,
[FromForm] string? rebuildScope,
[FromForm] bool dryRun = false,
[FromForm] bool queueDemand = false,
[FromForm] int? maxDemandToQueue = null,
[FromForm] string? appearancePreference = null)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var rebuild = await storyMemory.RebuildImportSessionAsync(
importSessionId,
userId,
new StoryMemoryRebuildOptions
{
DryRun = dryRun,
RebuildAll = !string.Equals(rebuildScope, "incremental", StringComparison.OrdinalIgnoreCase),
QueueDemand = queueDemand,
MaxDemandToQueue = maxDemandToQueue,
AppearancePreference = string.IsNullOrWhiteSpace(appearancePreference)
? StoryMemoryAppearancePreferences.Balanced
: appearancePreference.Trim()
},
HttpContext.RequestAborted);
if (rebuild.DryRun)
{
TempData["StoryIntelligenceReplayMessage"] = $"Dry run complete. {rebuild.SceneResultsRead:N0} stored scene result(s), {rebuild.SceneResultsProcessed:N0} parseable. No database changes, AI calls, or image credits.";
}
else
{
TempData["StoryIntelligenceReplayMessage"] = $"Durable Story Memory rebuilt. {rebuild.SceneResultsProcessed:N0} scene result(s) processed, {rebuild.AssignmentsCreatedOrValidated:N0} assignment(s), {rebuild.DemandsCreated:N0} demand record(s), {rebuild.AiCalls:N0} AI calls.";
}
return RedirectToAction(nameof(StoryIntelligenceExperience), new { importSessionId, mode = "replay" });
}
[HttpGet("StoryMemoryDiagnostics")]
public async Task<IActionResult> StoryMemoryDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0)
{
return BadRequest("A valid importSessionId is required.");
}
var diagnostics = await storyMemoryRepository.GetDiagnosticsAsync(importSessionId);
return Json(diagnostics);
}
[HttpGet("StoryIntelligenceIllustrationDiagnostics")]
public async Task<IActionResult> StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var model = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (model is null)
{
return NotFound($"Import Session {importSessionId:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet.");
}
return View(model);
}
private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message)
=> new()
{
Mode = "error",
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = message,
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real",
Warning = message
}
};
}

View File

@ -1,61 +0,0 @@
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,
ILogger<StoryIntelligenceVisualisationController> logger) : 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);
if (snapshot is not null)
{
var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.",
snapshot.ImportSessionId,
snapshot.RunId,
current?.ChapterLabel,
current?.SceneNumber,
snapshot.Diagnostics.SelectedCurrentSceneResultId,
snapshot.ChangeToken);
}
return snapshot is null ? NotFound() : Ok(snapshot);
}
[HttpGet("import-sessions/{importSessionId:int}/visualisation-snapshot")]
public async Task<IActionResult> GetImportSessionSnapshot(int importSessionId)
{
if (currentUser.UserId is not int userId)
{
return Unauthorized();
}
var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (snapshot is not null)
{
var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.",
snapshot.ImportSessionId,
snapshot.RunId,
current?.ChapterLabel,
current?.SceneNumber,
snapshot.Diagnostics.SelectedCurrentSceneResultId,
snapshot.ChangeToken);
}
return snapshot is null ? NotFound() : Ok(snapshot);
}
}

View File

@ -1,117 +0,0 @@
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligenceIllustrationMatchingRepository
{
Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId);
Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request);
Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request);
}
public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceIllustrationMatchingRepository
{
public async Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
"""
SELECT
StoryIntelligenceCharacterIllustrationAssignmentID,
ProjectID,
CharacterKey,
CharacterName,
IllustrationLibraryItemID,
StableCode,
MatchConfidence,
MatchingScore,
CAST(NULL AS nvarchar(max)) AS EvidenceJson,
CAST(NULL AS nvarchar(max)) AS CandidateDiagnosticsJson,
ReasonChosen,
ReasonReassigned,
IsActive,
CreatedUtc,
UpdatedUtc,
AssignmentStatus,
AssignedUtc,
LastValidatedUtc,
EvidenceVersion,
IsFallback
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID
AND IsActive = 1;
""",
new { ProjectID = projectId });
return rows.ToList();
}
public async Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceCharacterIllustrationAssignment>(
"""
SET XACT_ABORT ON;
MERGE dbo.StoryIntelligenceCharacterIllustrationAssignments WITH (HOLDLOCK) AS target
USING (SELECT @ProjectID AS ProjectID, @CharacterKey AS CharacterKey) AS source
ON target.ProjectID = source.ProjectID
AND target.CharacterKey = source.CharacterKey
AND target.IsActive = 1
WHEN MATCHED THEN
UPDATE SET
CharacterName = @CharacterName,
IllustrationLibraryItemID = @IllustrationLibraryItemID,
StableCode = @StableCode,
MatchConfidence = @MatchConfidence,
MatchingScore = @MatchingScore,
EvidenceJson = @EvidenceJson,
CandidateDiagnosticsJson = @CandidateDiagnosticsJson,
ReasonChosen = @ReasonChosen,
ReasonReassigned = @ReasonReassigned,
AssignmentStatus = CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END,
LastValidatedUtc = SYSUTCDATETIME(),
EvidenceVersion = N'21K',
IsFallback = CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned, AssignmentStatus, AssignedUtc, LastValidatedUtc, EvidenceVersion, IsFallback)
VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned, CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, SYSUTCDATETIME(), SYSUTCDATETIME(), N'21K', CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END);
SELECT *
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID
AND CharacterKey = @CharacterKey
AND IsActive = 1;
""",
request);
}
public async Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceIllustrationDemand>(
"""
SET XACT_ABORT ON;
MERGE dbo.StoryIntelligenceIllustrationDemands WITH (HOLDLOCK) AS target
USING (SELECT @ProjectID AS ProjectID, @DemandKey AS DemandKey) AS source
ON ISNULL(target.ProjectID, -1) = ISNULL(source.ProjectID, -1)
AND target.DemandKey = source.DemandKey
WHEN MATCHED THEN
UPDATE SET
RequestCount = RequestCount + 1,
LastRequestedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, DemandKey, AgeBand, Presentation, HairColour, SkinTone, HairLength, Glasses, FacialHair)
VALUES (@ProjectID, @DemandKey, @AgeBand, @Presentation, @HairColour, @SkinTone, @HairLength, @Glasses, @FacialHair);
SELECT *
FROM dbo.StoryIntelligenceIllustrationDemands
WHERE ISNULL(ProjectID, -1) = ISNULL(@ProjectID, -1)
AND DemandKey = @DemandKey;
""",
request);
}
}

View File

@ -12,6 +12,7 @@ public interface IStoryIntelligencePipelineRepository
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId); Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request); Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId);
} }
public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository
@ -81,4 +82,13 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
return rows.ToList(); return rows.ToList();
} }
public async Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceImportNotificationTarget>(
"dbo.StoryIntelligenceBookPipeline_TryBeginCompletionNotification",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
} }

View File

@ -1,70 +0,0 @@
namespace PlotLine.Models;
public sealed class StoryIntelligenceCharacterIllustrationAssignment
{
public int StoryIntelligenceCharacterIllustrationAssignmentID { get; init; }
public int ProjectID { get; init; }
public string CharacterKey { get; init; } = string.Empty;
public string CharacterName { get; init; } = string.Empty;
public int? IllustrationLibraryItemID { get; init; }
public string? StableCode { get; init; }
public decimal MatchConfidence { get; init; }
public int MatchingScore { get; init; }
public string? EvidenceJson { get; init; }
public string? CandidateDiagnosticsJson { get; init; }
public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; }
public int? ImportSessionID { get; init; }
public string AssignmentStatus { get; init; } = "Assigned";
public DateTime? AssignedUtc { get; init; }
public DateTime? LastValidatedUtc { get; init; }
public DateTime? InvalidatedUtc { get; init; }
public string? InvalidationReason { get; init; }
public string EvidenceVersion { get; init; } = "21K";
public bool IsFallback { get; init; }
public bool IsActive { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed record StoryIntelligenceCharacterIllustrationAssignmentSave(
int ProjectID,
string CharacterKey,
string CharacterName,
int? IllustrationLibraryItemID,
string? StableCode,
decimal MatchConfidence,
int MatchingScore,
string? EvidenceJson,
string? CandidateDiagnosticsJson,
string? ReasonChosen,
string? ReasonReassigned);
public sealed class StoryIntelligenceIllustrationDemand
{
public int StoryIntelligenceIllustrationDemandID { get; init; }
public int? ProjectID { get; init; }
public string DemandKey { get; init; } = string.Empty;
public string AgeBand { get; init; } = string.Empty;
public string Presentation { get; init; } = string.Empty;
public string HairColour { get; init; } = string.Empty;
public string SkinTone { get; init; } = string.Empty;
public string? HairLength { get; init; }
public string? Glasses { get; init; }
public string? FacialHair { get; init; }
public int RequestCount { get; init; }
public DateTime LastRequestedUtc { get; init; }
public DateTime? QueuedUtc { get; init; }
public string GenerationSource { get; init; } = "Demand generation";
}
public sealed record StoryIntelligenceIllustrationDemandRequest(
int? ProjectID,
string DemandKey,
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string? HairLength,
string? Glasses,
string? FacialHair);

View File

@ -69,6 +69,22 @@ public sealed class StoryIntelligenceBookPipelineState
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase); && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase);
} }
public sealed class StoryIntelligenceImportNotificationTarget
{
public int ImportSessionID { get; init; }
public int UserID { get; init; }
public int ProjectID { get; init; }
public string ProjectName { get; init; } = string.Empty;
public int BookID { get; init; }
public string BookTitle { get; init; } = string.Empty;
public string? BookSubtitle { get; init; }
public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle);
public int ChapterCount { get; init; }
public int CompletedChapterCount { get; init; }
public int FailedChapterCount { get; init; }
public string NotificationKind { get; init; } = "Success";
}
public sealed class StoryIntelligenceBookPipelineSaveRequest public sealed class StoryIntelligenceBookPipelineSaveRequest
{ {
public int ProjectID { get; init; } public int ProjectID { get; init; }

View File

@ -148,7 +148,6 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>(); builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>(); builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>(); builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>(); builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>(); builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>(); builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
@ -225,12 +224,13 @@ 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<IStoryMemoryService, StoryMemoryService>(); builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>(); builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>(); builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>();
builder.Services.AddScoped<IStoryIntelligenceCompletionNotificationService, StoryIntelligenceCompletionNotificationService>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>(); builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IIllustrationPromptBuilder, IllustrationPromptBuilder>(); builder.Services.AddScoped<IIllustrationPromptBuilder, IllustrationPromptBuilder>();
@ -238,7 +238,6 @@ public class Program
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>(); builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>(); builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>(); builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingService, StoryIntelligenceIllustrationMatchingService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>(); builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>(); builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>(); builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();

View File

@ -50,6 +50,7 @@ public interface IEmailService
Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail); Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail); Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail);
Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail); Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
Task SendStoryIntelligenceImportNotificationAsync(string email, string displayName, StoryIntelligenceImportNotificationTarget target);
} }
public interface ICurrentUserService public interface ICurrentUserService

View File

@ -180,6 +180,38 @@ public sealed class EmailService(
} }
} }
public Task SendStoryIntelligenceImportNotificationAsync(string email, string displayName, StoryIntelligenceImportNotificationTarget target)
{
var failed = string.Equals(target.NotificationKind, "Failure", StringComparison.OrdinalIgnoreCase);
var subject = failed
? $"PlotDirector import needs attention: {target.BookDisplayTitle}"
: $"PlotDirector import complete: {target.BookDisplayTitle}";
var statusLine = failed
? "The manuscript import has stopped with one or more chapters needing attention."
: "The manuscript import has finished and is ready for review.";
var actionLine = failed
? "Please open PlotDirector to review the failed chapter analysis before continuing."
: "Please open PlotDirector to review and commit the imported scenes.";
var body = $"""
Hi {DisplayName(displayName)},
{statusLine}
Project: {target.ProjectName}
Book: {target.BookDisplayTitle}
Chapters processed: {target.CompletedChapterCount:N0} of {target.ChapterCount:N0}
Failed chapters: {target.FailedChapterCount:N0}
{actionLine}
Thanks,
The PlotDirector team
""";
return QueueAsync(email, DisplayName(displayName), subject, body, html: null);
}
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html) private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html)
{ {
if (string.IsNullOrWhiteSpace(toEmail)) if (string.IsNullOrWhiteSpace(toEmail))

View File

@ -649,14 +649,12 @@ public interface IIllustrationLibraryService
Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed); Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed);
Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId); Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId);
Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form); Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form);
Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype);
} }
public sealed class IllustrationLibraryService( public sealed class IllustrationLibraryService(
IIllustrationLibraryRepository repository, IIllustrationLibraryRepository repository,
IIllustrationPromptBuilder promptBuilder, IIllustrationPromptBuilder promptBuilder,
IIllustrationImageProvider provider, IIllustrationImageProvider provider) : IIllustrationLibraryService
IUploadStorageService uploadStorage) : IIllustrationLibraryService
{ {
private const int MaxGenerationAttempts = 3; private const int MaxGenerationAttempts = 3;
@ -953,115 +951,4 @@ public sealed class IllustrationLibraryService(
return new(true, "Illustration metadata updated."); return new(true, "Illustration metadata updated.");
} }
public async Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var lookup = items
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.Where(item => item.IsActive && !item.IsArchived)
.Where(item => !item.IsFallbackClassification)
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
.ThenByDescending(item => item.UpdatedUtc)
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var scene in prototype.Scenes)
{
var locationFallback = scene.Location.ImagePath;
var locationResolution = ResolvePrototypeImage(scene.Location.Id, scene.Location.LibraryCode, locationFallback, lookup);
scene.Location.ImageResolution = locationResolution;
scene.Location.ImagePath = locationResolution.FinalImageUrl ?? locationFallback;
foreach (var character in scene.Characters)
{
var fallback = character.ImagePath;
var resolution = ResolvePrototypeImage(character.Id, character.LibraryCode, fallback, lookup);
character.ImageResolution = resolution;
character.ImagePath = resolution.FinalImageUrl ?? fallback;
}
foreach (var asset in scene.Assets)
{
var fallback = asset.ImagePath;
var resolution = ResolvePrototypeImage(asset.Id, asset.LibraryCode, fallback, lookup);
asset.ImageResolution = resolution;
asset.ImagePath = resolution.FinalImageUrl ?? fallback;
}
}
}
private StoryIntelligenceExperienceImageResolution ResolvePrototypeImage(
string entityId,
string stableCode,
string fallbackImageUrl,
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
{
if (string.IsNullOrWhiteSpace(stableCode) || !lookup.TryGetValue(stableCode, out var candidates))
{
return PrototypeFallback(entityId, stableCode, fallbackImageUrl);
}
foreach (var candidate in candidates)
{
var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath);
if (publicUrl is null)
{
continue;
}
return new()
{
EntityId = entityId,
StableCode = stableCode,
IllustrationLibraryItemId = candidate.IllustrationLibraryItemID,
Status = candidate.Status,
FinalImageUrl = publicUrl,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = false
};
}
var firstCandidate = candidates.FirstOrDefault();
return new()
{
EntityId = entityId,
StableCode = stableCode,
IllustrationLibraryItemId = firstCandidate?.IllustrationLibraryItemID,
Status = firstCandidate?.Status,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true
};
}
private string? ResolveExistingPublicUploadUrl(string? storedPath)
{
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
if (physicalPath is null || !File.Exists(physicalPath))
{
return null;
}
var normalised = storedPath!.Replace('\\', '/').Trim();
if (!normalised.StartsWith('/'))
{
normalised = "/" + normalised.TrimStart('/');
}
return EncodeUrlPath(normalised);
}
private static StoryIntelligenceExperienceImageResolution PrototypeFallback(string entityId, string stableCode, string fallbackImageUrl)
=> new()
{
EntityId = entityId,
StableCode = stableCode,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true
};
private static string EncodeUrlPath(string path)
=> string.Join(
'/',
path.Split('/', StringSplitOptions.None)
.Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment)));
} }

View File

@ -45,6 +45,7 @@ public sealed class OnboardingStoryIntelligenceService(
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IStoryIntelligenceEntitlementService entitlement,
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
{ {
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2"; private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
@ -93,6 +94,7 @@ public sealed class OnboardingStoryIntelligenceService(
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null) public async Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null)
{ {
var userId = RequireUserId(); var userId = RequireUserId();
await entitlement.EnsureCanQueueAsync(userId);
var context = await GetContextAsync(userId); var context = await GetContextAsync(userId);
if (context is null) if (context is null)
{ {

View File

@ -25,6 +25,8 @@ public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
IStoryMemoryService storyMemory, IStoryMemoryService storyMemory,
ICharacterIntelligenceService characterIntelligence, ICharacterIntelligenceService characterIntelligence,
IStoryIntelligenceEntitlementService entitlement,
IStoryIntelligenceCompletionNotificationService completionNotifications,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{ {
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
@ -80,6 +82,8 @@ public sealed class PersistedStoryIntelligenceRunner(
"Story Intelligence run has no source text."); "Story Intelligence run has no source text.");
} }
await entitlement.EnsureCanExecuteAsync(run.UserID);
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText); var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText);
if (paragraphs.Count == 0) if (paragraphs.Count == 0)
@ -462,6 +466,7 @@ public sealed class PersistedStoryIntelligenceRunner(
completedScenes: completedScenes, completedScenes: completedScenes,
failedScenes: failedScenes, failedScenes: failedScenes,
totalTokens: totals.TotalTokens); totalTokens: totals.TotalTokens);
await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken);
} }
catch (StoryIntelligenceRunCancelledException) catch (StoryIntelligenceRunCancelledException)
{ {
@ -490,6 +495,7 @@ public sealed class PersistedStoryIntelligenceRunner(
"Analysis could not finish.", "Analysis could not finish.",
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message); errorMessage: ex.Message);
await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken);
} }
catch (StoryIntelligenceRunFailureException ex) catch (StoryIntelligenceRunFailureException ex)
{ {
@ -502,6 +508,7 @@ public sealed class PersistedStoryIntelligenceRunner(
"Analysis could not finish.", "Analysis could not finish.",
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message); errorMessage: ex.Message);
await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken);
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
@ -519,6 +526,7 @@ public sealed class PersistedStoryIntelligenceRunner(
"Analysis could not finish.", "Analysis could not finish.",
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message); errorMessage: ex.Message);
await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken);
} }
} }

View File

@ -144,9 +144,12 @@ WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = St
DELETE FROM dbo.StoryMemoryImportPreferences DELETE FROM dbo.StoryMemoryImportPreferences
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID); WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID);
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NOT NULL
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID) BEGIN
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID)
OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID); OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID);
END;
DELETE FROM dbo.StoryIntelligenceIllustrationDemands DELETE FROM dbo.StoryIntelligenceIllustrationDemands
WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID); WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID);

View File

@ -14,6 +14,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IStoryIntelligencePipelineStateService pipelines, IStoryIntelligencePipelineStateService pipelines,
IStoryIntelligenceEntitlementService entitlement,
ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService
{ {
private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required."; private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required.";
@ -26,6 +27,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
public async Task<StoryIntelligenceExistingChapterQueueViewModel> QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form) public async Task<StoryIntelligenceExistingChapterQueueViewModel> QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form)
{ {
await entitlement.EnsureCanQueueAsync(userId);
if (form.ChapterID is not int chapterId) if (form.ChapterID is not int chapterId)
{ {
return await BuildViewModelAsync( return await BuildViewModelAsync(

View File

@ -19,6 +19,7 @@ public sealed class StoryIntelligenceFullChapterTestService(
IStoryIntelligencePipelineStateService pipelines, IStoryIntelligencePipelineStateService pipelines,
IOptions<StoryIntelligenceOptions> options, IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions, IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceEntitlementService entitlement,
ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService
{ {
private const int LargeChapterCharacterThreshold = 40000; private const int LargeChapterCharacterThreshold = 40000;
@ -74,6 +75,7 @@ public sealed class StoryIntelligenceFullChapterTestService(
public async Task<int> QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile) public async Task<int> QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile)
{ {
await entitlement.EnsureCanQueueAsync(userId);
var source = await ReadSourceAsync(form, textFile); var source = await ReadSourceAsync(form, textFile);
if (!string.IsNullOrWhiteSpace(source.ErrorMessage)) if (!string.IsNullOrWhiteSpace(source.ErrorMessage))
{ {

View File

@ -0,0 +1,90 @@
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceEntitlementService
{
Task EnsureCanQueueAsync(int userId);
Task EnsureCanExecuteAsync(int userId);
}
public sealed class StoryIntelligenceEntitlementService(ISubscriptionService subscriptions) : IStoryIntelligenceEntitlementService
{
public Task EnsureCanQueueAsync(int userId) => EnsureAllowedAsync(userId);
public Task EnsureCanExecuteAsync(int userId) => EnsureAllowedAsync(userId);
private async Task EnsureAllowedAsync(int userId)
{
var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId);
if (!IsActive(subscription, DateTime.UtcNow))
{
throw new SubscriptionLimitException("Story Intelligence requires an active subscription before paid AI work can be queued or processed.");
}
}
private static bool IsActive(UserSubscriptionDetail? subscription, DateTime utcNow)
{
if (subscription is null || !subscription.IsActive || !subscription.SubscriptionLevelIsActive)
{
return false;
}
if (subscription.EndDateUTC.HasValue && subscription.EndDateUTC.Value <= utcNow)
{
return false;
}
if (subscription.CurrentPeriodEndUTC.HasValue
&& subscription.CurrentPeriodEndUTC.Value <= utcNow
&& !string.IsNullOrWhiteSpace(subscription.StripeSubscriptionId))
{
return false;
}
var status = (subscription.StripeStatus ?? string.Empty).Trim().ToLowerInvariant();
return status is not ("canceled" or "cancelled" or "incomplete_expired" or "ended" or "expired" or "inactive");
}
}
public interface IStoryIntelligenceCompletionNotificationService
{
Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default);
}
public sealed class StoryIntelligenceCompletionNotificationService(
IStoryIntelligencePipelineRepository pipelines,
IUserRepository users,
IEmailService emails,
ILogger<StoryIntelligenceCompletionNotificationService> logger) : IStoryIntelligenceCompletionNotificationService
{
public async Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default)
{
if (run.BookID is not int bookId)
{
return;
}
var target = await pipelines.TryBeginCompletionNotificationAsync(bookId, run.UserID);
if (target is null)
{
return;
}
var user = await users.GetByIdAsync(target.UserID);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
{
logger.LogWarning("Story Intelligence import notification skipped because user {UserID} has no email target.", target.UserID);
return;
}
await emails.SendStoryIntelligenceImportNotificationAsync(user.Email, user.DisplayName, target);
logger.LogInformation(
"Queued Story Intelligence import {NotificationKind} notification for import session {ImportSessionID}, book {BookID}, user {UserID}.",
target.NotificationKind,
target.ImportSessionID,
target.BookID,
target.UserID);
}
}

View File

@ -18,6 +18,7 @@ public sealed class StoryIntelligenceResultPersistenceService(
IStoryIntelligenceResultRepository repository, IStoryIntelligenceResultRepository repository,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
ISceneImportResolver importResolver, ISceneImportResolver importResolver,
IStoryIntelligenceEntitlementService entitlement,
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@ -63,6 +64,7 @@ public sealed class StoryIntelligenceResultPersistenceService(
public async Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form) public async Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form)
{ {
await entitlement.EnsureCanQueueAsync(userId);
if (string.IsNullOrWhiteSpace(form.ChapterText)) if (string.IsNullOrWhiteSpace(form.ChapterText))
{ {
throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run."); throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run.");

View File

@ -0,0 +1,122 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'CompletionEmailQueuedUtc') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceBookPipelines
ADD CompletionEmailQueuedUtc datetime2 NULL;
END;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'FailureEmailQueuedUtc') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceBookPipelines
ADD FailureEmailQueuedUtc datetime2 NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryBeginCompletionNotification
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ImportSessionID int;
DECLARE @ProjectID int;
DECLARE @HasActiveRuns bit;
DECLARE @HasFailedRuns bit;
DECLARE @ChapterCount int;
DECLARE @CompletedChapterCount int;
DECLARE @FailedChapterCount int;
DECLARE @NotificationKind nvarchar(20);
SELECT @ImportSessionID = p.StoryIntelligenceBookPipelineID,
@ProjectID = p.ProjectID
FROM dbo.StoryIntelligenceBookPipelines p
INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
WHERE p.BookID = @BookID;
IF @ImportSessionID IS NULL
RETURN;
SELECT @HasActiveRuns = CASE WHEN EXISTS
(
SELECT 1
FROM dbo.StoryIntelligenceRuns
WHERE BookID = @BookID
AND UserID = @UserID
AND Status IN (N'Pending', N'Running')
) THEN 1 ELSE 0 END;
IF @HasActiveRuns = 1
RETURN;
SELECT @ChapterCount = COUNT(1),
@CompletedChapterCount = SUM(CASE WHEN Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END),
@FailedChapterCount = SUM(CASE WHEN Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END),
@HasFailedRuns = CASE WHEN SUM(CASE WHEN Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END
FROM dbo.StoryIntelligenceRuns
WHERE BookID = @BookID
AND UserID = @UserID;
IF COALESCE(@ChapterCount, 0) = 0
RETURN;
SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END;
BEGIN TRANSACTION;
IF @NotificationKind = N'Failure'
BEGIN
UPDATE dbo.StoryIntelligenceBookPipelines
SET FailureEmailQueuedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceBookPipelineID = @ImportSessionID
AND FailureEmailQueuedUtc IS NULL;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
RETURN;
END;
END
ELSE
BEGIN
UPDATE dbo.StoryIntelligenceBookPipelines
SET CompletionEmailQueuedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceBookPipelineID = @ImportSessionID
AND CompletionEmailQueuedUtc IS NULL;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
RETURN;
END;
END;
COMMIT TRANSACTION;
SELECT @ImportSessionID AS ImportSessionID,
@UserID AS UserID,
p.ProjectID,
project.ProjectName,
p.BookID,
book.BookTitle,
book.Subtitle AS BookSubtitle,
@ChapterCount AS ChapterCount,
@CompletedChapterCount AS CompletedChapterCount,
@FailedChapterCount AS FailedChapterCount,
@NotificationKind AS NotificationKind
FROM dbo.StoryIntelligenceBookPipelines p
INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID
INNER JOIN dbo.Books book ON book.BookID = p.BookID
WHERE p.StoryIntelligenceBookPipelineID = @ImportSessionID;
END;
GO

View File

@ -0,0 +1,10 @@
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments;
END
GO

View File

@ -141,11 +141,6 @@
{ {
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Index">Continue in background</a> <a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Index">Continue in background</a>
} }
@if (Model.ImportSessionID.HasValue)
{
<a class="btn btn-outline-primary"
href="/Development/StoryIntelligenceExperience?importSessionId=@Model.ImportSessionID.Value&mode=replay">Replay Story Intelligence</a>
}
@if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit)) @if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit))
{ {
<a class="btn btn-primary" <a class="btn btn-primary"

View File

@ -0,0 +1,107 @@
# Story Intelligence Import Visualisation Archive
## Purpose
This document preserves the removed Story Intelligence import visualisation so the design can be revisited without leaving it active in the product.
## Original Rationale
The visualisation was built to make long-running manuscript import feel alive. It showed PlotDirector reading scenes, discovering characters, assigning locations and assets, drawing relationships, and replaying persisted import sessions. The aim was reassurance and transparency during expensive AI-driven work.
## User Experience
The active experience lived behind development routes and was also linked from the onboarding import progress page as "Replay Story Intelligence". Users could open a full-screen theatre-style view that selected a live or persisted import session, animated progress, and showed a scene-by-scene story map.
## Layout
The page used a dark full-screen shell with a header status strip, circular progress treatment, left-side activity and discovery panels, central living story stage, character/location/asset presentation, SVG relationship lines, a scene ribbon/timeline, right-side insight panels, replay controls, diagnostics, and rebuild controls.
## Look And Feel
The design used a cinematic blue/gold visual style, translucent panels, soft glow states, animated graph lines, circular portraits, visual asset chips, and compact diagnostics. Reduced-motion handling existed in CSS and JavaScript.
## Major Screens And Components
- `/Development/StoryIntelligenceExperience`
- `/Development/StoryIntelligenceExperience?mode=simulation`
- `/Development/StoryIntelligenceExperience?importSessionId=...&mode=replay`
- `/Development/StoryIntelligenceIllustrationDiagnostics`
- `/api/story-intelligence/runs/{runId}/visualisation-snapshot`
- `/api/story-intelligence/import-sessions/{importSessionId}/visualisation-snapshot`
## Animation Behaviour
The JavaScript polled snapshot endpoints, reconciled scene/entity state, animated current-scene changes, drew SVG relationship connections, preloaded image assets, managed replay stepping, and updated timeline/ribbon state.
## Progress Behaviour
The visualisation consumed persisted Story Intelligence run state and SignalR-progress-adjacent data. It was an observer in principle, but the development rebuild route could trigger Story Memory rebuild and optional image demand creation, which made the visual layer too close to active import processing.
## Discoveries Shown
It displayed detected characters, locations, assets, relationships, knowledge threads, questions, scene summaries, evidence snippets, observations, and diagnostic labels.
## Character Presentation
Characters were rendered as weighted stage nodes with names, roles, relevance, image resolution state, fallback portraits, and evidence-derived presentation metadata. Some matching logic used Story Memory and illustration metadata to pick or queue portrait candidates.
## Location Presentation
Locations were rendered as current-scene place cards or stage nodes, backed by scene setting, location extraction, Story Memory locations, and optional illustration-library assignment.
## Evidence Presentation
Evidence appeared as compact cards and text items. Some scene observations were useful for human review, but some existed primarily to make the visualisation feel intelligent.
## Image Presentation
The visualisation used prototype SVG assets, approved illustration-library images, persisted Story Memory assignments, and image-resolution diagnostics. Generated image demand was originally tied too closely to Story Memory and visualisation rebuild behaviour.
## Completion Behaviour
The visualisation could replay completed persisted results. It did not need to drive import completion and should not have been part of the normal user path.
## SignalR Behaviour
The ordinary import progress page still uses the shared Story Intelligence progress hub. The removed visualisation used polling snapshot endpoints and client-side reconciliation rather than being required for background processing.
## Major Runtime Classes
- `PlotLine.Controllers.DevelopmentController`
- `PlotLine.Controllers.StoryIntelligenceVisualisationController`
- `PlotLine.Services.StoryIntelligenceVisualisationSnapshotService`
- `PlotLine.Services.StoryIntelligenceExperiencePrototypeData`
- `PlotLine.Services.StoryIntelligenceIllustrationMatchingService`
- `PlotLine.ViewModels.StoryIntelligenceExperiencePrototypeViewModels`
- `PlotLine.Views.Development.StoryIntelligenceExperience`
- `PlotLine.Views.Development.StoryIntelligenceIllustrationDiagnostics`
## Archived Source Snapshot
Representative self-contained UI/source artefacts are preserved under:
`docs/story-intelligence/visualisation-archive/runtime-source/`
This includes Razor markup, CSS, JavaScript, prototype SVG assets, the visualisation view models, and prototype data builder. This archive is outside the compiled app and must not participate in runtime.
Backend classes that were too coupled to duplicate in full remain recoverable through Git at safety tag:
`pre-visualisation-removal-2026-08-24`
## Database Dependencies
The visualisation consumed persisted Story Intelligence run/result tables, Story Intelligence book import session tables, illustration library tables, Story Memory tables, illustration assignment tables, and illustration demand tables. The lean import path keeps persisted import/progress data and Story Memory where product features consume it, but removes active snapshot/replay UI.
## Story Intelligence Dependencies
The visualisation depended on chapter structure results, scene intelligence results, persisted scene summaries, characters, locations, assets, relationships, knowledge changes, questions, observations, Story Memory assignments, and character profile updates.
## AI Dependencies
Opening the visualisation should not have required AI. The problematic coupling was indirect: rebuild and Story Memory/image demand paths could create additional work intended for the visual experience. The lean architecture keeps AI for necessary import interpretation and profile updates, but image generation is off by default and visualisation no longer triggers analysis or demand.
## Lessons Learned
A future visualisation may observe information naturally generated by the import pipeline, but it must never cause additional analysis or AI work.
Visualisation should be a passive read model over durable import state. It should never introduce prompt fields, persistence, retries, image demand, or Story Memory work that the product does not otherwise need.

View File

@ -0,0 +1,317 @@
# Story Intelligence Import Visualisation Preservation Audit
## 1. Purpose and History
This document preserves the current Story Intelligence import visualisation before any decision to remove it from the active import workflow. The intended future boundary is:
```text
IMPORT / STORY INTELLIGENCE
-> persisted results and domain progress
-> read-only visualisation
```
The visualisation must not cause extra analysis, extraction, persistence, AI calls, or image generation just to make the display interesting.
This audit is based on source inspection only. No application code, SQL, or runtime data was changed. Screenshots were not captured because doing that safely would require an authenticated development session and existing import data; the existing design reference is `/srv/repos/PlotDirector/PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Visualisation_Design.png`.
## 2. Current UX
There are two related experiences:
- Active onboarding progress page: `/srv/repos/PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml`. This is the production-facing progress page. It shows "Reading your manuscript", a plain import progress card, chapters read, scenes read, failures, elapsed time, estimated remaining, current chapter, current scene, latest scene summary, recent discoveries, cancel, continue in background, replay, and review actions.
- Development experience visualisation: `/srv/repos/PlotDirector/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml`. This is admin/development-only and uses the full visual theatre: dark full-screen shell, header status strip, circular progress ring, left-side discovery/activity panels, central "living story view", character/location stage, SVG relationship lines, scene ribbon, timeline, right-side knowledge/observation/relationship panels, replay controls, and diagnostics.
The production page explicitly says processing can continue after the browser tab is closed. The full visualisation is reachable from the production progress page by a "Replay Story Intelligence" link when an import session exists, but the controller route is protected by `[Authorize(Policy = "AdminOnly")]` and `environment.IsDevelopment()`.
The distinctive visual language lives mostly in `/srv/repos/PlotDirector/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css`: dark navy/blue-gold stage, blurred translucent panels, conic progress ring, animated SVG connection lines, circular character/location portraits, text-only evidence cards when portraits are not trusted, glowing timeline markers, compact side panels, and reduced-motion handling. Client behaviour is in `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js`: it polls snapshots, reconciles nodes, animates progress, preloads images, draws collision-aware connections, updates scene ribbon/timeline, and opens insight detail cards.
## 3. Architecture
The active import pipeline is separate from the full visualisation:
- `/srv/repos/PlotDirector/PlotLine/Services/OnboardingStoryIntelligenceService.cs` queues one persisted Story Intelligence run per included chapter.
- `/srv/repos/PlotDirector/PlotLine/Services/PersistedStoryIntelligenceWorker.cs` is the background service that repeatedly asks the runner to process pending runs.
- `/srv/repos/PlotDirector/PlotLine/Services/PersistedStoryIntelligenceRunner.cs` performs the expensive work: one Chapter Structure call, one Scene Intelligence call per detected scene, JSON repair when needed, validation, persistence, progress publication, Story Memory processing, and Character Intelligence profile updates.
- `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs` builds read models from persisted run/scene results and Story Memory assignments.
- `/srv/repos/PlotDirector/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs` exposes read-only JSON snapshots for authenticated users.
- `/srv/repos/PlotDirector/PlotLine/Controllers/DevelopmentController.cs` serves the full experience page and its replay/rebuild action, development-only.
- `/srv/repos/PlotDirector/PlotLine/Hubs/StoryIntelligenceHub.cs` plus `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceProgressNotifier.cs` provide reusable SignalR progress updates.
The full visualisation is mostly a read-only observer when simply opened. The dangerous coupling is that Story Memory and illustration-demand machinery can be used by the import pipeline and by the development replay/rebuild path, so "visualisation support" has grown into durable derived state.
## 4. Component Inventory
| Component | Classification | Notes |
|---|---|---|
| `/srv/repos/PlotDirector/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs` | VISUALISATION ONLY | Read-only snapshot API for run/import session visualisation. |
| `/srv/repos/PlotDirector/PlotLine/Controllers/DevelopmentController.cs` methods `StoryIntelligenceExperience`, `RebuildStoryIntelligenceExperience`, diagnostics | VISUALISATION ONLY / UNCERTAIN | Development-only UI and rebuild tools. Rebuild calls Story Memory and can create demand records. |
| `/srv/repos/PlotDirector/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml` | VISUALISATION ONLY | Full-screen experience/replay UI. |
| `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js` | VISUALISATION ONLY | Snapshot polling, animation, layout, drawing, replay controls. |
| `/srv/repos/PlotDirector/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css` | VISUALISATION ONLY | Full visual design system for the prototype. |
| `/srv/repos/PlotDirector/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs` | VISUALISATION ONLY | Snapshot DTOs: scenes, characters, locations, assets, relationships, diagnostics, image resolution. |
| `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs` | DO NOT REMOVE UNTIL DECOUPLED | Builds visualization snapshots and contains visual-only inference/story-memory logic. |
| `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs` | VISUALISATION ONLY | Simulation data. |
| `/srv/repos/PlotDirector/PlotLine/wwwroot/images/story-intelligence/prototype/*` | VISUALISATION ONLY | Static fallback prototype assets. |
| `/srv/repos/PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml` | IMPORT PIPELINE / SHARED UX | Active import status screen; contains replay link. |
| `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-progress.js` | SHARED INFRASTRUCTURE | Production progress SignalR client for onboarding and legacy dashboards. |
| `/srv/repos/PlotDirector/PlotLine/Hubs/StoryIntelligenceHub.cs` | SHARED INFRASTRUCTURE | Authenticated SignalR hub for progress. |
| `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceProgressNotifier.cs` | SHARED INFRASTRUCTURE | Broadcasts persisted run progress events. |
| `/srv/repos/PlotDirector/PlotLine/Services/PersistedStoryIntelligenceRunner.cs` | IMPORT PIPELINE / STORY INTELLIGENCE | AI execution, persistence, progress, Story Memory, Character Intelligence. |
| `/srv/repos/PlotDirector/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md` | STORY INTELLIGENCE | Boundary prompt; explicitly excludes story entities/observations. |
| `/srv/repos/PlotDirector/PlotLine/Docs/AI/Scene-Prompt-V2.md` | STORY INTELLIGENCE / VISUALISATION COUPLED | Main scene schema includes many fields consumed by import and visualisation, including `characterAppearance`. |
| `/srv/repos/PlotDirector/PlotLine/Services/StoryMemoryServices.cs` | STORY INTELLIGENCE / DO NOT REMOVE UNTIL DECOUPLED | Durable derived scene memory and illustration assignment/demand. |
| `/srv/repos/PlotDirector/PlotLine/Data/StoryMemoryRepository.cs` | STORY INTELLIGENCE / SHARED INFRASTRUCTURE | Persistence for Story Memory. Note: currently uses inline SQL. |
| `/srv/repos/PlotDirector/PlotLine/Services/IllustrationLibraryServices.cs` | SHARED INFRASTRUCTURE | Reusable image library and image generation worker integration. |
| `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs` | VISUALISATION COUPLED / UNCERTAIN | Older project-level assignment/demand based on visualisation DTOs. |
| `/srv/repos/PlotDirector/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs` | VISUALISATION COUPLED / UNCERTAIN | Project-level visualisation assignment/demand tables. |
| SQL `114`, `115`, `117`, `121`, `127`, `137`, `142`, `144`, `145`, `146`, `147` | IMPORT/STORY INTELLIGENCE | Persisted runs, result storage, pipeline state, Story Memory, character context, safe image policy. |
| SQL `135`, `136`, `138`, `139`, `140`, `141` | IMAGE/VISUALISATION COUPLED | Illustration library, matching, demand, audit. |
## 5. Event Catalogue
| Event/update | Example | Source | Persisted? | AI derived? | Required elsewhere? |
|---|---|---|---|---|---|
| Import started | "Story Intelligence has started reading this chapter." | `PersistedStoryIntelligenceRunner.PublishAsync` | Run progress fields yes | No | Yes, production progress |
| Chapter started | "PlotDirector is finding the scene boundaries..." | Runner before Chapter Structure call | Run progress fields yes | No | Yes |
| Scene boundaries detected | "Detected 4 suggested scenes." | Runner after Chapter Structure response | Yes, chapter result and run counts | Yes | Yes |
| Scene analysis started | "Reading scene 2." | Runner before Scene Intelligence call | Run progress fields yes | No | Yes |
| Scene analysis completed | "Scene 2 analysed." | Runner after scene persistence | Yes, scene result and run counts | Yes | Yes |
| Latest scene summary | Scene short summary | Scene prompt `summary.short` | Yes in `ParsedJson`; SignalR transient copy | Yes | Yes, review/progress |
| Recent discoveries | "Possible character found: Beth" | Runner derives from parsed characters/locations/assets | SignalR transient; source persisted | Yes source, No derivation | Nice-to-have |
| Failure | "Scene 2 needs attention." | Runner catch blocks | Yes | Sometimes | Yes |
| Cancellation | "Analysis was cancelled." | Runner cancellation path | Yes | No | Yes |
| Completion | "Chapter analysis complete." | Runner completion path | Yes | No | Yes |
| Snapshot change token | hash of run/result state | Visualisation snapshot service | No | No | Visualisation only |
| Current scene/window | latest persisted scene result | Visualisation snapshot service | No | No | Visualisation only |
| Character node | name, role, relevance, weight | Visualisation snapshot service from scene characters plus story memory | No as DTO; source persisted | Mostly yes | Review/import uses source, not node |
| Location node | active location image/text | Snapshot service from scene locations/setting | No as DTO; source persisted | Mostly yes | Review/import uses source |
| Asset state | important assets, often not rendered stage nodes | Snapshot service from scene assets | No as DTO; source persisted | Mostly yes | Review/import uses source |
| Relationship lines | labels/states/weights | Snapshot service from scene relationships and visual memory inference | Some source persisted; inferred visual memory no | Mixed | Explicit relationships yes; hard-coded visual inference no |
| Knowledge threads | questions and knowledge changes | Snapshot service from scene JSON | No as DTO; source persisted | Yes | Knowledge import uses knowledge changes |
| Observations | title/detail evidence cards | Snapshot service from `scene.Observations` | No as DTO; source persisted | Yes | Unclear/no strong current consumer outside visualisation |
| Image demand status | demand recorded/queued/satisfied | Story Memory or old matching service | Yes | No direct text AI; may trigger image generation later | Images useful to visualisation, maybe library |
| Image generated | library item status generated | Illustration worker/library | Yes | Image model call | Shared library, but demand originates from SI memory/visualisation coupling |
## 6. Visualisation-Driven Intelligence
Information already required by PlotDirector:
- Scene boundaries, scene summaries, scene references, and detected scene counts are core import outputs.
- Characters, aliases, locations, assets, relationships, knowledge changes, metrics, source limits, and continuity signals are used by review/import services.
- Character aliases and known-character context are used by the prompt to reduce identity drift.
- Character appearance has a downstream consumer: Story Memory and illustration matching. It is not required for scene creation itself, but it is required by the current image assignment/demand system.
Information that appears primarily visualisation-oriented:
- `StoryIntelligenceExperience*` DTOs, image resolution diagnostics, current visual scene window, display weights, stage labels, timeline labels, feed items, SVG connection labels, replay indexes, panel counts, and change tokens.
- Snapshot-service in-memory `StoryMemory` used to carry characters, point-of-view, and relationships through the visual replay.
- Hard-coded/thematic inference in `StoryIntelligenceVisualisationSnapshotService`, including Beth/Grace/Elen family inference and summary-language inference. This is not the same as canonical relationship import.
- `scene.Observations` as a displayed "Story observations" panel. It is generated by Scene Prompt V2 and persisted in scene JSON, but source searches found no strong downstream consumer outside visualisation/Story Memory evidence extraction.
Information with uncertain value:
- Character appearance and deterministic appearance inference. It is useful only if reusable illustrations remain part of Story Intelligence or future passive visualisation.
- Durable Story Memory entities. They may become useful for future context/intent or import review, but current normal import review services still primarily read persisted scene results rather than requiring Story Memory.
## 7. AI Cost Attribution
| AI operation | Required by Import | Required by SI | Required by Visualisation | Could remove? |
|---|---:|---:|---:|---|
| Chapter Structure prompt | Yes | Yes | No | No |
| Scene Intelligence prompt per scene | Yes | Yes | Partially: it includes fields the visualisation displays | Not wholesale |
| JSON repair retry | Yes for reliability | Yes | No | No, unless schema/output complexity is reduced enough to lower failures |
| Character Intelligence profile update | No for basic import | Yes/current architecture | No direct | Possibly defer or make opt-in |
| Story Memory deterministic processing | No for basic import | Partially/current architecture | Yes for image/visual continuity | Decouple before removing |
| Illustration library image generation | No for manuscript import | No for textual SI | Yes/partially for visual experience | Yes for simplified import |
| Development replay/rebuild | No | No | Yes | Yes, keep as offline/admin-only if needed |
Answer: the visualisation partially increases AI cost. Opening the snapshot endpoint does not itself call the text AI. But the current scene prompt asks for `characterAppearance` specifically for illustration matching, Story Memory can queue image demand during import, and development rebuild can create demand records. If automatic image generation is enabled and approval is not required, those demands can lead to image-generation calls.
## 8. Evidence and Observation System
The "Beth pours tea" style evidence comes from Scene Prompt V2 and the persisted `SceneIntelligenceScene` JSON, not from a separate visualisation prompt. Relevant fields include:
- `characters[].actions`, `characters[].notes`
- `relationships[].relationshipSignal`, `relationships[].evidence`
- `knowledgeChanges[].evidence`
- `questionsRaised[].evidence`
- `observations[].description`, `observations[].predicate`, `observations[].objectName`, `observations[].evidence`
- `characterAppearance[].evidence`
Representation:
- Models are in `/srv/repos/PlotDirector/PlotLine/Models/StoryIntelligencePersistenceModels.cs` and `/srv/repos/PlotDirector/PlotLine/Models/StoryIntelligenceImportPreviewModels.cs`.
- Scene results are stored by `/srv/repos/PlotDirector/PlotLine/Data/StoryIntelligenceResultRepository.cs` via `dbo.StoryIntelligenceSceneResult_Save`.
- The visualisation reads `ParsedJson`, deserializes `SceneIntelligenceScene`, and projects observations into `StoryIntelligenceExperienceTextItem`.
Consumers:
- Character/location/asset/relationship/knowledge import services read scene JSON.
- Story Memory reads characters, appearance, locations, assets, and relationships.
- The visualisation explicitly renders observations. No clear normal user-facing consumer of `scene.Observations` was identified outside the visualisation-style experience.
Classification:
- CORE STORY INTELLIGENCE: scene summary, characters, relationships, locations, assets, knowledge changes, source limits.
- USEFUL DERIVED INTELLIGENCE: known-character aliases, deterministic appearance facts if image matching stays, Story Memory identities if future context/intent uses them.
- VISUALISATION TELEMETRY: visual DTOs, panel state, change token, replay index, display weights, latest-discovery phrasing, activity feed.
- UNUSED / AI EXHAUST: `observations` appears likely unless a downstream product consumer is added.
## 9. Image-Generation Lifecycle
Current route:
```text
Scene result saved
-> StoryMemoryService.ProcessSceneResultAsync
-> Upsert durable memory characters/locations/assets/relationships
-> ResolveIllustrationsAsync
-> match approved/generated illustration library items
-> if none, upsert demand
-> if QueueDemand enabled, create/queue planned library item
-> IllustrationGenerationWorker claims queued item
-> OpenAI image generation
-> uploaded file persisted to illustration library
-> future snapshots/replays can reuse assignment/library item
```
Findings:
- Existing library images are checked first. Story Memory filters active, non-archived, non-fallback, generated/approved items and scores hard compatibility before queueing demand.
- Duplicate demand is limited by `ImportSessionID + EntityType + ArchetypeKey`; alias drift can still create separate canonical identity keys if alias resolution has not merged them.
- Image demands are tied to Story Memory temporary/import-session entities, not normal canonical Character records.
- The generated image is stored in the Story Intelligence illustration library, not automatically assigned to the canonical `Characters` table or normal character gallery/avatar.
- Subsequent chapters in the same import can reuse Story Memory assignments and library items.
- Subsequent imports can reuse the global library item if compatible, but per-import Story Memory assignments are separate.
- Failed/partial imports can leave generated or queued library items and demand records because the image library is independent of final import commit.
- The visualisation itself benefits from images, but normal manuscript import does not require generated images.
Every route that can incur image generation cost:
- Story Memory during the active runner, when `AutomaticImageGenerationDuringImportEnabled == true`, `RequireImageGenerationApproval == false`, and caps allow queueing.
- Development replay/rebuild path if called with `queueDemand=true`, followed by the image worker.
- Admin illustration library/starter/regeneration workflows outside import.
- Older `StoryIntelligenceIllustrationMatchingService` can upsert planned items/queue demand from visualisation DTOs; it should be treated as visualisation-coupled until proven otherwise.
## 10. SignalR and Reusable Infrastructure
Preserve:
- `/srv/repos/PlotDirector/PlotLine/Hubs/StoryIntelligenceHub.cs`
- `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceProgressNotifier.cs`
- `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-progress.js`
- `StoryIntelligenceRunProgressEvent`
These are not obsolete visualisation code. They power the production progress screen and are a natural fit for the simplified unattended import.
The full visualisation does not use SignalR directly; it polls read-only snapshot endpoints. The onboarding/progress UI uses SignalR and should remain as lightweight progress infrastructure.
## 11. Preservation Inventory
PRESERVE AS ACTIVE INFRASTRUCTURE:
- Persisted run/result pipeline, background worker, queue/claim/complete/fail/cancel stored procedures.
- SignalR hub/notifier and production progress page.
- Story Intelligence review/import services for scenes, characters, locations, assets, relationships, and knowledge.
- Email queue infrastructure, although no completion email for Story Intelligence was found.
- Illustration library infrastructure if retained for user/admin image workflows.
PRESERVE FOR FUTURE VISUALISATION:
- Full visual design ideas from `StoryIntelligenceExperience.cshtml`, `story-intelligence-experience-prototype.css`, and `story-intelligence-experience-prototype.js`.
- Read-only snapshot endpoint pattern.
- Replay controls and ability to reconstruct from persisted results.
- Timeline/ribbon, relationship-line drawing, text fallback cards, insight detail panels, and diagnostics.
- Static fallback prototype assets.
SAFE TO REMOVE after documentation and route cleanup:
- Development-only simulation data and static prototype images, if no future visual replay is planned.
- Replay link from active onboarding, if the full visualisation is removed from the workflow.
- Visualisation-specific DTOs and snapshot endpoints, after confirming no external client uses them.
DO NOT REMOVE UNTIL DECOUPLED:
- Story Memory processing from the active runner, because it is currently intertwined with illustration assignment/demand and durable derived state.
- `characterAppearance` in the scene prompt until image matching/Story Memory decisions are made.
- Old project-level `StoryIntelligenceIllustrationMatchingService` and repository until references and tables are audited for production use.
- Any SQL tables/procs in the 135-147 range without mapping them to current consumers.
## 12. Future Visualisation Boundary
A future visualisation should subscribe only to:
- `ImportSessionStatusChanged`
- `ChapterRunQueued`
- `ChapterRunStarted`
- `ChapterRunCompleted`
- `SceneResultPersisted`
- `ReviewStageAvailable`
- `ImportFailed`
- `ImportCompleted`
- `ImageAssignmentAvailable` only when an image exists naturally
Each event should contain IDs, counts, status, display-safe summary, and links to persisted records. It should not contain prompt-only display demands. Visualisation should load data through read-only queries against persisted scene results, canonical import previews, and existing image assignments.
The future visualisation must not:
- Add prompt fields.
- Trigger Story Memory rebuild.
- Queue image demand.
- Persist visual-only inferred relationships.
- Request JSON repair.
- Cause additional AI calls.
## 13. Simplified Unattended-Import Requirements
Already exists:
- Durable pending/running/completed/failed runs.
- Background processing independent of the browser.
- Persisted progress/status fields.
- Cancellation.
- Production progress page that says users can leave and return later.
- Email queue infrastructure and user email addresses.
Missing or incomplete:
- Story Intelligence completion email was not found.
- A single authoritative import-session completion event/worker action should queue email once.
- Production UX should return users directly to review/import results, not development replay.
- AI/image cost gates should be explicit before queueing text/image work.
- Story Memory and image demand should be optional/deferred for unattended import.
- Progress should rely on persisted pipeline state, not in-browser visual state.
## 14. Screenshots and References
- Existing design reference: `/srv/repos/PlotDirector/PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Visualisation_Design.png`
- Experience design document: `/srv/repos/PlotDirector/PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Experience_Design_v2.md`
- Active progress page: `/srv/repos/PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml`
- Full experience page: `/srv/repos/PlotDirector/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml`
- Full experience CSS: `/srv/repos/PlotDirector/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css`
- Full experience JS: `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js`
## 15. Known Good Design Ideas Worth Preserving
- "Living story view" as a stage, not a table.
- Progress ring plus plain status numbers.
- Character/location prominence based on relevance and point of view.
- Relationship lines with visual tone for support/conflict/uncertainty.
- Scene ribbon and timeline to show motion through the manuscript.
- Text-only evidence cards when image confidence is low.
- Replay from persisted results.
- Compact diagnostic panel for engineering trust.
- Respect for reduced motion.
- "Return to Import" route back to the actual workflow.
## Recommended Subsequent Task
Do not remove anything yet. The next implementation task should:
1. Remove the development replay link from the active onboarding flow or hide it behind an admin/development flag.
2. Add a completion-email path driven by persisted import-session completion.
3. Disable automatic image demand during normal import by default and require explicit user/admin approval before image generation.
4. Split Story Memory into two modes: minimal domain memory required by import/context, and optional visual/image memory.
5. Trim Scene Prompt V2 so `observations` and `characterAppearance` are included only when a downstream product feature genuinely consumes them.
6. Replace visualisation DTO dependencies in image matching with domain DTOs, or retire the old project-level matching service.
7. Keep a passive snapshot/replay prototype that reads persisted results only.