Phase 21N.1 - Story Intelligence Payload and Resume Repair
This commit is contained in:
parent
a54da80ea2
commit
f80734a80c
@ -1,6 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Http.Metadata;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PlotLine.Controllers;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
@ -48,7 +51,11 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
|
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
|
||||||
("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),
|
||||||
|
("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)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var test in tests)
|
foreach (var test in tests)
|
||||||
@ -746,6 +753,78 @@ static void IllustrationProviderOmitsGptImageResponseFormat()
|
|||||||
Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message.");
|
Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void ScanReviewPostSupportsFullBookFormSubmissions()
|
||||||
|
{
|
||||||
|
var method = typeof(OnboardingController).GetMethod(nameof(OnboardingController.SaveScanReview));
|
||||||
|
Assert(method is not null, "Scan review post action could not be found.");
|
||||||
|
|
||||||
|
var formLimits = method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: false)
|
||||||
|
.OfType<RequestFormLimitsAttribute>()
|
||||||
|
.SingleOrDefault();
|
||||||
|
Assert(formLimits is not null, "Scan review post action should configure expanded form limits.");
|
||||||
|
Assert(formLimits!.ValueCountLimit >= 20000, "Scan review form value limit should support full-book previews.");
|
||||||
|
Assert(formLimits.KeyLengthLimit >= 4096, "Scan review form key length limit should support indexed review fields.");
|
||||||
|
|
||||||
|
var requestSize = method.GetCustomAttributes(typeof(RequestSizeLimitAttribute), inherit: false)
|
||||||
|
.OfType<RequestSizeLimitAttribute>()
|
||||||
|
.SingleOrDefault();
|
||||||
|
Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
|
||||||
|
{
|
||||||
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||||
|
Assert(!view.Contains("JsonSerializer.Serialize(Model", StringComparison.Ordinal), "Live experience page should not serialise the full view model into the Razor response.");
|
||||||
|
Assert(view.Contains("JsonSerializer.Serialize(bootModel", StringComparison.Ordinal), "Live experience page should serialise only boot data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LiveVisualisationStripsIllustrationDiagnostics()
|
||||||
|
{
|
||||||
|
var large = new string('x', 200_000);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
||||||
|
Assert(!source.Contains("previousStoredEvidence = existing?.EvidenceJson", StringComparison.Ordinal), "Evidence trace must not recursively embed previous EvidenceJson.");
|
||||||
|
Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash.");
|
||||||
|
}
|
||||||
|
|
||||||
static JsonSerializerOptions JsonOptions()
|
static JsonSerializerOptions JsonOptions()
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -490,6 +490,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("scan-review")]
|
[HttpPost("scan-review")]
|
||||||
|
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||||
|
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
|
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
|
||||||
{
|
{
|
||||||
|
|||||||
@ -17,7 +17,27 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti
|
|||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
|
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
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
|
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
|
||||||
WHERE ProjectID = @ProjectID
|
WHERE ProjectID = @ProjectID
|
||||||
AND IsActive = 1;
|
AND IsActive = 1;
|
||||||
|
|||||||
@ -47,8 +47,12 @@ public class Program
|
|||||||
builder.Configuration.GetSection("StoryIntelligence").Bind(options);
|
builder.Configuration.GetSection("StoryIntelligence").Bind(options);
|
||||||
});
|
});
|
||||||
builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
|
builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
|
||||||
|
var dataProtectionKeyPath = GetDataProtectionKeyPath(
|
||||||
|
builder.Environment.ContentRootPath,
|
||||||
|
builder.Configuration["DataProtection:KeysPath"]);
|
||||||
|
Directory.CreateDirectory(dataProtectionKeyPath);
|
||||||
builder.Services.AddDataProtection()
|
builder.Services.AddDataProtection()
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyPath));
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(options =>
|
.AddCookie(options =>
|
||||||
{
|
{
|
||||||
@ -275,4 +279,21 @@ public class Program
|
|||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetDataProtectionKeyPath(string contentRootPath, string? configuredPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(configuredPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentRoot = new DirectoryInfo(contentRootPath);
|
||||||
|
if (string.Equals(contentRoot.Name, "current", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& contentRoot.Parent is not null)
|
||||||
|
{
|
||||||
|
return Path.Combine(contentRoot.Parent.FullName, "App_Data", "DataProtectionKeys");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(contentRoot.FullName, "App_Data", "DataProtectionKeys");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -579,8 +579,35 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId);
|
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
|
||||||
if (committedRuns.Count == 0)
|
.OrderBy(run => run.ChapterNumber)
|
||||||
|
.ThenBy(run => run.StoryIntelligenceRunID)
|
||||||
|
.Select(run => new OnboardingStoryIntelligenceBatchItem
|
||||||
|
{
|
||||||
|
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
|
||||||
|
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
|
||||||
|
ChapterTitle = run.ChapterTitle,
|
||||||
|
ChapterID = run.ChapterID,
|
||||||
|
RunID = run.StoryIntelligenceRunID
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var activeRuns = committedRuns.Count > 0
|
||||||
|
? []
|
||||||
|
: (await runs.ListRunsByBookForUserAsync(bookId, userId))
|
||||||
|
.Where(run => run.BookID == state.BookID)
|
||||||
|
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
|
||||||
|
.ThenBy(run => run.StoryIntelligenceRunID)
|
||||||
|
.Select(run => new OnboardingStoryIntelligenceBatchItem
|
||||||
|
{
|
||||||
|
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
|
||||||
|
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
|
||||||
|
ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!,
|
||||||
|
ChapterID = run.ChapterID ?? 0,
|
||||||
|
RunID = run.StoryIntelligenceRunID
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var batchItems = committedRuns.Count > 0 ? committedRuns : activeRuns;
|
||||||
|
if (batchItems.Count == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -594,18 +621,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
BookID = state.BookID,
|
BookID = state.BookID,
|
||||||
ProjectName = state.ProjectName,
|
ProjectName = state.ProjectName,
|
||||||
BookTitle = state.BookDisplayTitle,
|
BookTitle = state.BookDisplayTitle,
|
||||||
Items = committedRuns
|
Items = batchItems
|
||||||
.OrderBy(run => run.ChapterNumber)
|
|
||||||
.ThenBy(run => run.StoryIntelligenceRunID)
|
|
||||||
.Select(run => new OnboardingStoryIntelligenceBatchItem
|
|
||||||
{
|
|
||||||
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
|
|
||||||
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
|
|
||||||
ChapterTitle = run.ChapterTitle,
|
|
||||||
ChapterID = run.ChapterID,
|
|
||||||
RunID = run.StoryIntelligenceRunID
|
|
||||||
})
|
|
||||||
.ToList()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
|
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
|
||||||
|
|||||||
@ -246,16 +246,45 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
|||||||
character = observation.Name,
|
character = observation.Name,
|
||||||
identityKey = observation.CharacterKey,
|
identityKey = observation.CharacterKey,
|
||||||
canonicalIdentity = observation.CharacterKey,
|
canonicalIdentity = observation.CharacterKey,
|
||||||
rawExtractedEvidence = observation.IdentityEvidence,
|
rawExtractedEvidence = CompactEvidence(observation.IdentityEvidence),
|
||||||
sceneEvidence = observation.TextEvidence,
|
sceneEvidence = CompactEvidence(observation.TextEvidence),
|
||||||
relationshipEvidence = observation.RelationshipEvidence,
|
relationshipEvidence = CompactEvidence(observation.RelationshipEvidence),
|
||||||
mergedEvidence = observation.TextEvidence.Concat(observation.RelationshipEvidence).ToArray(),
|
mergedEvidence = CompactEvidence(observation.TextEvidence.Concat(observation.RelationshipEvidence)),
|
||||||
previousStoredEvidence = existing?.EvidenceJson,
|
previousAssignment = existing is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
existing.StableCode,
|
||||||
|
existing.IllustrationLibraryItemID,
|
||||||
|
existing.MatchConfidence,
|
||||||
|
existing.MatchingScore,
|
||||||
|
evidenceChars = existing.EvidenceJson?.Length ?? 0,
|
||||||
|
evidenceHash = string.IsNullOrWhiteSpace(existing.EvidenceJson) ? null : StableHash(existing.EvidenceJson)
|
||||||
|
},
|
||||||
finalMatcherEvidence = observation.Evidence,
|
finalMatcherEvidence = observation.Evidence,
|
||||||
evidenceSource = "Story Intelligence visualisation character state",
|
evidenceSource = "Story Intelligence visualisation character state",
|
||||||
canonicalMergeReason = "Character state ID from canonical visualisation identity"
|
canonicalMergeReason = "Character state ID from canonical visualisation identity"
|
||||||
}, JsonOptions);
|
}, JsonOptions);
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> CompactEvidence(IEnumerable<string> evidence)
|
||||||
|
=> evidence
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => Trim(value, 160))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(16)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static string Trim(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clean = Regex.Replace(value.Trim(), @"\s+", " ");
|
||||||
|
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd() + "...";
|
||||||
|
}
|
||||||
|
|
||||||
private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
|
private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
|
||||||
{
|
{
|
||||||
var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");
|
var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");
|
||||||
|
|||||||
@ -25,6 +25,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
|
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
|
||||||
{
|
{
|
||||||
private const string FallbackRoot = "/images/story-intelligence/prototype";
|
private const string FallbackRoot = "/images/story-intelligence/prototype";
|
||||||
|
private const int SceneWindowPrevious = 4;
|
||||||
|
private const int SceneWindowFollowing = 2;
|
||||||
|
private const int SnapshotWarningBytes = 1024 * 1024;
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
@ -116,7 +119,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
|
|
||||||
var current = PickCurrentScene(run, completed);
|
var current = PickCurrentScene(run, completed);
|
||||||
var storyMemory = new StoryMemory();
|
var storyMemory = new StoryMemory();
|
||||||
var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory).ToList();
|
var visibleSceneIds = VisibleSceneResultIds(completed, current, SceneWindowPrevious, SceneWindowFollowing);
|
||||||
|
var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory, includedSceneResultIds: visibleSceneIds).ToList();
|
||||||
if (scenes.Count == 0)
|
if (scenes.Count == 0)
|
||||||
{
|
{
|
||||||
scenes.Add(BuildEmptyScene(run));
|
scenes.Add(BuildEmptyScene(run));
|
||||||
@ -144,7 +148,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
|
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
|
||||||
ApplyIllustrationDiagnostics(model);
|
ApplyIllustrationDiagnostics(model);
|
||||||
}
|
}
|
||||||
logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", runId, stopwatch.ElapsedMilliseconds);
|
StripLiveDiagnostics(model);
|
||||||
|
LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds);
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,6 +214,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
var scenes = new List<StoryIntelligenceExperienceSceneState>();
|
var scenes = new List<StoryIntelligenceExperienceSceneState>();
|
||||||
var nextSceneNumber = 1;
|
var nextSceneNumber = 1;
|
||||||
var storyMemory = new StoryMemory();
|
var storyMemory = new StoryMemory();
|
||||||
|
var allCompleted = runSnapshots.SelectMany(snapshot => snapshot.Completed).ToList();
|
||||||
|
var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, SceneWindowFollowing);
|
||||||
foreach (var snapshot in runSnapshots)
|
foreach (var snapshot in runSnapshots)
|
||||||
{
|
{
|
||||||
var runScenes = BuildSceneStates(
|
var runScenes = BuildSceneStates(
|
||||||
@ -227,7 +234,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
overallRemaining: overallRemaining,
|
overallRemaining: overallRemaining,
|
||||||
continuousSceneNumbering: true,
|
continuousSceneNumbering: true,
|
||||||
overallTerminal: isTerminal,
|
overallTerminal: isTerminal,
|
||||||
storyMemory: storyMemory)
|
storyMemory: storyMemory,
|
||||||
|
includedSceneResultIds: visibleSceneIds)
|
||||||
.ToList();
|
.ToList();
|
||||||
scenes.AddRange(runScenes);
|
scenes.AddRange(runScenes);
|
||||||
nextSceneNumber += snapshot.Completed.Count;
|
nextSceneNumber += snapshot.Completed.Count;
|
||||||
@ -272,7 +280,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
||||||
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
|
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
|
||||||
ApplyIllustrationDiagnostics(model);
|
ApplyIllustrationDiagnostics(model);
|
||||||
logger.LogInformation("Built Story Intelligence import session visualisation snapshot for session {ImportSessionId} in {ElapsedMs}ms.", importSessionId, stopwatch.ElapsedMilliseconds);
|
StripLiveDiagnostics(model);
|
||||||
|
LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds);
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,7 +301,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
string? overallRemaining = null,
|
string? overallRemaining = null,
|
||||||
bool continuousSceneNumbering = false,
|
bool continuousSceneNumbering = false,
|
||||||
bool overallTerminal = false,
|
bool overallTerminal = false,
|
||||||
StoryMemory? storyMemory = null)
|
StoryMemory? storyMemory = null,
|
||||||
|
IReadOnlySet<int>? includedSceneResultIds = null)
|
||||||
{
|
{
|
||||||
storyMemory ??= new StoryMemory();
|
storyMemory ??= new StoryMemory();
|
||||||
var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count);
|
var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count);
|
||||||
@ -304,6 +314,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
storyMemory.Update(scene, item);
|
storyMemory.Update(scene, item);
|
||||||
var sourceSceneNumber = SceneNumber(item);
|
var sourceSceneNumber = SceneNumber(item);
|
||||||
var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber;
|
var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber;
|
||||||
|
if (includedSceneResultIds is not null && !includedSceneResultIds.Contains(item.Result.SceneResultID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID;
|
var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID;
|
||||||
yield return new StoryIntelligenceExperienceSceneState
|
yield return new StoryIntelligenceExperienceSceneState
|
||||||
{
|
{
|
||||||
@ -1504,6 +1519,129 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void StripLiveDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model)
|
||||||
|
{
|
||||||
|
model.Diagnostics.CharacterConsistencyReport = Trim(model.Diagnostics.CharacterConsistencyReport, 2000);
|
||||||
|
foreach (var resolution in model.Scenes
|
||||||
|
.SelectMany(scene => scene.Characters.Select(character => character.ImageResolution)
|
||||||
|
.Concat(scene.Assets.Select(asset => asset.ImageResolution))
|
||||||
|
.Append(scene.Location.ImageResolution)))
|
||||||
|
{
|
||||||
|
StripLiveDiagnostics(resolution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StripLiveDiagnostics(StoryIntelligenceExperienceImageResolution resolution)
|
||||||
|
{
|
||||||
|
if (resolution is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolution.RejectedCandidates = null;
|
||||||
|
resolution.PreviousEvidence = null;
|
||||||
|
resolution.CurrentEvidence = null;
|
||||||
|
resolution.EvidenceWarnings = null;
|
||||||
|
resolution.ReasonChosen = Trim(resolution.ReasonChosen, 160);
|
||||||
|
resolution.ReasonReassigned = Trim(resolution.ReasonReassigned, 160);
|
||||||
|
resolution.PreviousIllustration = Trim(resolution.PreviousIllustration, 80);
|
||||||
|
resolution.DemandKey = Trim(resolution.DemandKey, 80);
|
||||||
|
resolution.DemandStatus = Trim(resolution.DemandStatus, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlySet<int> VisibleSceneResultIds(
|
||||||
|
IReadOnlyList<ParsedSceneResult> completed,
|
||||||
|
ParsedSceneResult? current,
|
||||||
|
int previousCount = SceneWindowPrevious,
|
||||||
|
int followingCount = SceneWindowFollowing)
|
||||||
|
{
|
||||||
|
if (completed.Count == 0)
|
||||||
|
{
|
||||||
|
return new HashSet<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentIndex = current is null
|
||||||
|
? completed.Count - 1
|
||||||
|
: completed.ToList().FindIndex(item => item.Result.SceneResultID == current.Result.SceneResultID);
|
||||||
|
if (currentIndex < 0)
|
||||||
|
{
|
||||||
|
currentIndex = completed.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = Math.Max(0, currentIndex - previousCount);
|
||||||
|
var end = Math.Min(completed.Count - 1, currentIndex + followingCount);
|
||||||
|
return completed
|
||||||
|
.Skip(start)
|
||||||
|
.Take(end - start + 1)
|
||||||
|
.Select(item => item.Result.SceneResultID)
|
||||||
|
.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogPayload(StoryIntelligenceExperiencePrototypeViewModel model, string scope, long elapsedMs)
|
||||||
|
{
|
||||||
|
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(model, JsonOptions).Length;
|
||||||
|
var counts = new
|
||||||
|
{
|
||||||
|
sceneCount = model.Scenes.Count,
|
||||||
|
characterCount = model.Scenes.Sum(scene => scene.Characters.Count),
|
||||||
|
locationCount = model.Scenes.Count(scene => !string.IsNullOrWhiteSpace(scene.Location.Name)),
|
||||||
|
assetCount = model.Scenes.Sum(scene => scene.Assets.Count),
|
||||||
|
relationshipCount = model.Scenes.Sum(scene => scene.Relationships.Count),
|
||||||
|
knowledgeCount = model.Scenes.Sum(scene => scene.KnowledgeThreads.Count),
|
||||||
|
observationCount = model.Scenes.Sum(scene => scene.Observations.Count),
|
||||||
|
activityCount = model.Scenes.Sum(scene => scene.ActivityFeed.Count)
|
||||||
|
};
|
||||||
|
var largestString = LargestStringProperty(model);
|
||||||
|
if (payloadBytes > SnapshotWarningBytes)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Story Intelligence snapshot payload for {Scope} is {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters; built in {ElapsedMs}ms.",
|
||||||
|
scope,
|
||||||
|
payloadBytes,
|
||||||
|
counts,
|
||||||
|
largestString.Path,
|
||||||
|
largestString.Length,
|
||||||
|
elapsedMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Built Story Intelligence visualisation snapshot for {Scope} in {ElapsedMs}ms; payload {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters.",
|
||||||
|
scope,
|
||||||
|
elapsedMs,
|
||||||
|
payloadBytes,
|
||||||
|
counts,
|
||||||
|
largestString.Path,
|
||||||
|
largestString.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model)
|
||||||
|
{
|
||||||
|
var largest = ("model", 0);
|
||||||
|
void Consider(string path, string? value)
|
||||||
|
{
|
||||||
|
if (value?.Length > largest.Item2)
|
||||||
|
{
|
||||||
|
largest = (path, value.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Consider("snapshotMessage", model.SnapshotMessage);
|
||||||
|
Consider("diagnostics.characterConsistencyReport", model.Diagnostics.CharacterConsistencyReport);
|
||||||
|
foreach (var scene in model.Scenes)
|
||||||
|
{
|
||||||
|
Consider($"{scene.Id}.summary", scene.Summary);
|
||||||
|
foreach (var character in scene.Characters)
|
||||||
|
{
|
||||||
|
Consider($"{scene.Id}.character.{character.Id}.name", character.Name);
|
||||||
|
Consider($"{scene.Id}.character.{character.Id}.relevance", character.Relevance);
|
||||||
|
Consider($"{scene.Id}.character.{character.Id}.rejectedCandidates", character.ImageResolution.RejectedCandidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (largest.Item1, largest.Item2);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsTerminal(string status)
|
private static bool IsTerminal(string status)
|
||||||
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;
|
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;
|
||||||
|
|
||||||
|
|||||||
@ -148,15 +148,15 @@ public sealed class StoryIntelligenceExperienceImageResolution
|
|||||||
public bool FallbackUsed { get; init; } = true;
|
public bool FallbackUsed { get; init; } = true;
|
||||||
public decimal? AssignmentConfidence { get; init; }
|
public decimal? AssignmentConfidence { get; init; }
|
||||||
public int? MatchingScore { get; init; }
|
public int? MatchingScore { get; init; }
|
||||||
public string? RejectedCandidates { get; init; }
|
public string? RejectedCandidates { get; set; }
|
||||||
public string? ReasonChosen { get; init; }
|
public string? ReasonChosen { get; set; }
|
||||||
public string? ReasonReassigned { get; init; }
|
public string? ReasonReassigned { get; set; }
|
||||||
public string? PreviousIllustration { get; init; }
|
public string? PreviousIllustration { get; set; }
|
||||||
public string? PreviousEvidence { get; init; }
|
public string? PreviousEvidence { get; set; }
|
||||||
public string? CurrentEvidence { get; init; }
|
public string? CurrentEvidence { get; set; }
|
||||||
public string? DemandKey { get; init; }
|
public string? DemandKey { get; set; }
|
||||||
public string? EvidenceWarnings { get; init; }
|
public string? EvidenceWarnings { get; set; }
|
||||||
public string? DemandStatus { get; init; }
|
public string? DemandStatus { get; set; }
|
||||||
public string? ResolvedPresentation { get; init; }
|
public string? ResolvedPresentation { get; init; }
|
||||||
public string? ResolvedAgeBand { get; init; }
|
public string? ResolvedAgeBand { get; init; }
|
||||||
public decimal? RelationshipDerivedConfidence { get; init; }
|
public decimal? RelationshipDerivedConfidence { get; init; }
|
||||||
|
|||||||
@ -18,6 +18,21 @@
|
|||||||
"error" => "No live snapshot",
|
"error" => "No live snapshot",
|
||||||
_ => "Choose a session"
|
_ => "Choose a session"
|
||||||
};
|
};
|
||||||
|
var bootModel = Model.Mode == "real"
|
||||||
|
? new StoryIntelligenceExperiencePrototypeViewModel
|
||||||
|
{
|
||||||
|
Mode = Model.Mode,
|
||||||
|
RunId = Model.RunId,
|
||||||
|
ImportSessionId = Model.ImportSessionId,
|
||||||
|
RunStatus = Model.RunStatus,
|
||||||
|
GeneratedUtc = Model.GeneratedUtc,
|
||||||
|
IsTerminal = false,
|
||||||
|
ProjectName = Model.ProjectName,
|
||||||
|
BookTitle = Model.BookTitle,
|
||||||
|
SnapshotMessage = Model.SnapshotMessage,
|
||||||
|
Diagnostics = Model.Diagnostics
|
||||||
|
}
|
||||||
|
: Model;
|
||||||
}
|
}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="dark" data-bs-theme="dark">
|
<html lang="en" data-theme="dark" data-bs-theme="dark">
|
||||||
@ -28,7 +43,7 @@
|
|||||||
<link rel="stylesheet" href="~/css/story-intelligence-experience-prototype.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/story-intelligence-experience-prototype.css" asp-append-version="true" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script>
|
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(bootModel, jsonOptions))</script>
|
||||||
|
|
||||||
<main class="story-exp"
|
<main class="story-exp"
|
||||||
data-story-experience
|
data-story-experience
|
||||||
@ -37,8 +52,8 @@
|
|||||||
data-run-id="@Model.RunId"
|
data-run-id="@Model.RunId"
|
||||||
data-import-session-id="@Model.ImportSessionId"
|
data-import-session-id="@Model.ImportSessionId"
|
||||||
data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)"
|
data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)"
|
||||||
data-change-token="@Model.ChangeToken"
|
data-change-token="@(Model.Mode == "real" ? string.Empty : Model.ChangeToken)"
|
||||||
data-terminal="@Model.IsTerminal.ToString().ToLowerInvariant()">
|
data-terminal="@((Model.Mode != "real" && Model.IsTerminal).ToString().ToLowerInvariant())">
|
||||||
<header class="story-exp-header" aria-label="Story Intelligence status">
|
<header class="story-exp-header" aria-label="Story Intelligence status">
|
||||||
<div class="story-exp-brand">
|
<div class="story-exp-brand">
|
||||||
<strong>PlotDirector</strong>
|
<strong>PlotDirector</strong>
|
||||||
|
|||||||
@ -77,8 +77,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!scenes.length) {
|
if (!scenes.length) {
|
||||||
|
state.changeToken = "";
|
||||||
|
state.currentSnapshot = { ...model, scenes: [] };
|
||||||
updateDiagnostics();
|
updateDiagnostics();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSceneState(index) {
|
function loadSceneState(index) {
|
||||||
@ -126,6 +127,12 @@
|
|||||||
scenes = model.scenes || [];
|
scenes = model.scenes || [];
|
||||||
state.changeToken = model.changeToken || state.changeToken;
|
state.changeToken = model.changeToken || state.changeToken;
|
||||||
state.terminal = !!model.isTerminal;
|
state.terminal = !!model.isTerminal;
|
||||||
|
if (!scenes.length) {
|
||||||
|
updateDiagnostics();
|
||||||
|
if (state.terminal) stopPolling("terminal model received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1));
|
state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1));
|
||||||
if (model.projectName) {
|
if (model.projectName) {
|
||||||
text(dom.projectName, model.projectName);
|
text(dom.projectName, model.projectName);
|
||||||
@ -1065,6 +1072,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startTimer() {
|
function startTimer() {
|
||||||
|
if (!scenes.length) return;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
if (!state.playing || !simulationMode) return;
|
if (!state.playing || !simulationMode) return;
|
||||||
state.timer = window.setInterval(() => {
|
state.timer = window.setInterval(() => {
|
||||||
@ -1384,6 +1392,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
|
if (!scenes.length) return;
|
||||||
const current = loadSceneState(state.index);
|
const current = loadSceneState(state.index);
|
||||||
const layout = sceneLayout(current);
|
const layout = sceneLayout(current);
|
||||||
reconcileCharacters(current, current, layout);
|
reconcileCharacters(current, current, layout);
|
||||||
@ -1394,6 +1403,7 @@
|
|||||||
|
|
||||||
if (window.ResizeObserver && dom.visualStage) {
|
if (window.ResizeObserver && dom.visualStage) {
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (!scenes.length) return;
|
||||||
const current = loadSceneState(state.index);
|
const current = loadSceneState(state.index);
|
||||||
const layout = sceneLayout(current);
|
const layout = sceneLayout(current);
|
||||||
reconcileCharacters(current, current, layout);
|
reconcileCharacters(current, current, layout);
|
||||||
@ -1403,8 +1413,12 @@
|
|||||||
observer.observe(dom.visualStage);
|
observer.observe(dom.visualStage);
|
||||||
}
|
}
|
||||||
|
|
||||||
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
|
if (scenes.length) {
|
||||||
startTimer();
|
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
|
||||||
|
startTimer();
|
||||||
|
} else {
|
||||||
|
updateDiagnostics();
|
||||||
|
}
|
||||||
startPolling();
|
startPolling();
|
||||||
if (realMode && !state.terminal) pollSnapshot();
|
if (realMode && !state.terminal) pollSnapshot();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user