Phase 21G.1 - Fix Live Snapshot Stalling
This commit is contained in:
parent
9989dfec1e
commit
34fdfb77fe
@ -52,15 +52,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var results = (await repository.ListSceneResultsAsync(runId))
|
||||
.OrderBy(result => result.TemporarySceneNumber)
|
||||
.ThenBy(result => result.SceneResultID)
|
||||
.ToList();
|
||||
var results = (await repository.ListSceneResultsAsync(runId)).ToList();
|
||||
var parsed = results
|
||||
.Select(result => new ParsedSceneResult(result, TryReadScene(result)))
|
||||
.Select(result => ReadSceneResult(runId, result))
|
||||
.OrderBy(item => SceneSortChapter(item))
|
||||
.ThenBy(item => SceneSortNumber(item))
|
||||
.ThenBy(item => item.Result.SceneResultID)
|
||||
.ToList();
|
||||
var completed = parsed
|
||||
.Where(item => item.Scene is not null && item.Result.ValidationErrorsCount == 0)
|
||||
.Where(item => item.Scene is not null)
|
||||
.ToList();
|
||||
|
||||
var current = PickCurrentScene(run, completed);
|
||||
@ -79,6 +79,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
ChangeToken = ChangeToken(run, results),
|
||||
IsTerminal = IsTerminal(run.Status),
|
||||
SnapshotMessage = SnapshotMessage(run, completed.Count),
|
||||
Diagnostics = BuildDiagnostics(run, results, parsed, completed, current),
|
||||
ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"),
|
||||
BookTitle = DisplayTitle(run.BookTitle, "Untitled book"),
|
||||
Scenes = scenes
|
||||
@ -158,13 +159,14 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
return null;
|
||||
}
|
||||
|
||||
return completed.LastOrDefault(item => SceneNumber(item) <= (run.CompletedScenes ?? int.MaxValue)) ?? completed.Last();
|
||||
return completed.Last();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene)
|
||||
{
|
||||
var povName = Clean(scene.PointOfView?.CharacterName);
|
||||
var states = new List<StoryIntelligenceExperienceCharacterState>();
|
||||
var reservedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var group in (scene.Characters ?? [])
|
||||
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
|
||||
.GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase)
|
||||
@ -172,10 +174,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
{
|
||||
var character = group.OrderByDescending(item => item.Confidence ?? 0).First();
|
||||
var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase);
|
||||
var libraryCode = CharacterLibraryCode(group.Key, character, scene, reservedCodes);
|
||||
reservedCodes.Add(libraryCode);
|
||||
states.Add(new StoryIntelligenceExperienceCharacterState
|
||||
{
|
||||
Id = CharacterId(group.Key),
|
||||
LibraryCode = CharacterLibraryCode(group.Key, character),
|
||||
LibraryCode = libraryCode,
|
||||
Name = group.Key,
|
||||
Role = isPov ? "POV character" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"),
|
||||
Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60),
|
||||
@ -186,10 +190,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(povName) && states.All(character => !string.Equals(character.Name, povName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var libraryCode = CharacterLibraryCode(povName, null, scene, reservedCodes);
|
||||
states.Insert(0, new StoryIntelligenceExperienceCharacterState
|
||||
{
|
||||
Id = CharacterId(povName),
|
||||
LibraryCode = CharacterLibraryCode(povName, null),
|
||||
LibraryCode = libraryCode,
|
||||
Name = povName,
|
||||
Role = "POV character",
|
||||
Relevance = "Point of view",
|
||||
@ -327,10 +332,27 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
yield return $"Run status: {run.Status}";
|
||||
}
|
||||
|
||||
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
|
||||
private ParsedSceneResult ReadSceneResult(int runId, StoryIntelligenceSavedSceneResult result)
|
||||
{
|
||||
var scene = TryReadScene(result, out var error);
|
||||
if (scene is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Story Intelligence visualisation skipped unparseable scene result {SceneResultId} for run {RunId}: {ParseError}",
|
||||
result.SceneResultID,
|
||||
runId,
|
||||
error ?? "Parsed scene was empty or missing schema version.");
|
||||
}
|
||||
|
||||
return new ParsedSceneResult(result, scene, error);
|
||||
}
|
||||
|
||||
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
|
||||
{
|
||||
safeError = null;
|
||||
if (string.IsNullOrWhiteSpace(result.ParsedJson))
|
||||
{
|
||||
safeError = "Parsed JSON was empty.";
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -348,19 +370,82 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
catch (JsonException ex)
|
||||
{
|
||||
safeError = ex.Message;
|
||||
}
|
||||
|
||||
safeError ??= "Parsed JSON did not contain a scene payload.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> results)
|
||||
{
|
||||
var raw = $"{run.StoryIntelligenceRunID}|{run.Status}|{run.UpdatedUtc:O}|{results.Count}|{results.Select(item => item.SceneResultID).DefaultIfEmpty().Max()}";
|
||||
var resultToken = string.Join(';', results
|
||||
.OrderBy(result => result.SceneResultID)
|
||||
.Select(result => string.Join('|',
|
||||
result.SceneResultID,
|
||||
result.ChapterID,
|
||||
result.SceneID,
|
||||
result.TemporarySceneNumber,
|
||||
result.ValidationErrorsCount,
|
||||
result.ValidationWarningsCount,
|
||||
result.InputTokens,
|
||||
result.OutputTokens,
|
||||
result.TotalTokens,
|
||||
result.DurationMs,
|
||||
result.CreatedUtc.ToUniversalTime().Ticks,
|
||||
StableContentHash(result.ParsedJson))));
|
||||
var raw = string.Join('|',
|
||||
run.StoryIntelligenceRunID,
|
||||
run.Status,
|
||||
run.CurrentStage,
|
||||
run.CurrentMessage,
|
||||
run.CompletedScenes,
|
||||
run.FailedScenes,
|
||||
run.TotalDetectedScenes,
|
||||
run.CompletedUtc?.ToUniversalTime().Ticks,
|
||||
run.UpdatedUtc.ToUniversalTime().Ticks,
|
||||
run.TotalInputTokens,
|
||||
run.TotalOutputTokens,
|
||||
run.TotalTokens,
|
||||
run.TotalDurationMs,
|
||||
run.ErrorMessage,
|
||||
run.ErrorDetail is null ? string.Empty : StableContentHash(run.ErrorDetail),
|
||||
results.Count,
|
||||
resultToken);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics(
|
||||
StoryIntelligenceSavedRun run,
|
||||
IReadOnlyList<StoryIntelligenceSavedSceneResult> results,
|
||||
IReadOnlyList<ParsedSceneResult> parsed,
|
||||
IReadOnlyList<ParsedSceneResult> completed,
|
||||
ParsedSceneResult? current)
|
||||
{
|
||||
var failed = parsed.Where(item => item.Scene is null).ToList();
|
||||
var warning = failed.Count == 0
|
||||
? null
|
||||
: $"{failed.Count:N0} scene result(s) skipped because they could not be parsed.";
|
||||
|
||||
return new()
|
||||
{
|
||||
RunStatus = run.Status,
|
||||
PersistedCompletedSceneCount = run.CompletedScenes ?? 0,
|
||||
TotalPersistedSceneResultCount = results.Count,
|
||||
SuccessfullyParsedSceneResultCount = completed.Count,
|
||||
FailedSceneResultCount = failed.Count,
|
||||
HighestPersistedSceneResultId = results.Count == 0 ? null : results.Max(result => result.SceneResultID),
|
||||
HighestParsedSceneResultId = completed.Count == 0 ? null : completed.Max(item => item.Result.SceneResultID),
|
||||
SelectedCurrentSceneResultId = current?.Result.SceneResultID,
|
||||
SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {SceneSortChapter(current):0.##}, Scene {SceneSortNumber(current):0.##}",
|
||||
CurrentChangeToken = ChangeToken(run, results),
|
||||
AnalysisCursor = FirstConfigured(run.CurrentMessage, run.CurrentStage),
|
||||
Warning = warning
|
||||
};
|
||||
}
|
||||
|
||||
private static string SnapshotMessage(StoryIntelligenceSavedRun run, int completedScenes)
|
||||
=> run.Status switch
|
||||
{
|
||||
@ -377,6 +462,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
private static int SceneNumber(ParsedSceneResult item)
|
||||
=> item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber;
|
||||
|
||||
private static decimal SceneSortChapter(ParsedSceneResult item)
|
||||
=> item.Scene?.SceneReference?.ChapterNumber
|
||||
?? (item.Result.ChapterID.HasValue ? item.Result.ChapterID.Value : 0);
|
||||
|
||||
private static decimal SceneSortNumber(ParsedSceneResult item)
|
||||
=> item.Scene?.SceneReference?.SceneNumber ?? item.Result.TemporarySceneNumber;
|
||||
|
||||
private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber)
|
||||
=> FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}");
|
||||
|
||||
@ -459,13 +551,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
|| value is "door" or "table" or "chair";
|
||||
}
|
||||
|
||||
private static string CharacterLibraryCode(string name, SceneIntelligenceCharacter? character)
|
||||
private static string CharacterLibraryCode(
|
||||
string name,
|
||||
SceneIntelligenceCharacter? character,
|
||||
SceneIntelligenceScene? scene = null,
|
||||
IReadOnlySet<string>? reservedCodes = null)
|
||||
{
|
||||
var value = $"{name} {character?.RoleInScene} {character?.Notes}".ToLowerInvariant();
|
||||
if (value.Contains("red") || value.Contains("auburn") || value.Contains("witness")) return "char-young-adult-red-haired-witness";
|
||||
if (value.Contains("older") || value.Contains("mother") || value.Contains("remember")) return "char-older-soft-presence";
|
||||
if (value.Contains("suspect") || value.Contains("threat") || value.Contains("man")) return "char-middle-aged-weathered-man";
|
||||
return "char-young-adult-brunette-observer";
|
||||
var profile = CharacterProfile.From(name, character, scene);
|
||||
var candidates = CharacterCodeProfiles
|
||||
.OrderByDescending(candidate => candidate.Score(profile))
|
||||
.ThenBy(candidate => reservedCodes?.Contains(candidate.Code) == true ? 1 : 0)
|
||||
.ThenBy(candidate => StableHash($"{name}|{candidate.Code}"))
|
||||
.ToList();
|
||||
|
||||
return candidates.FirstOrDefault(candidate => reservedCodes?.Contains(candidate.Code) != true)?.Code
|
||||
?? candidates.First().Code;
|
||||
}
|
||||
|
||||
private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting)
|
||||
@ -498,6 +598,14 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
"char-young-adult-red-haired-witness" => $"{FallbackRoot}/character-rosie.svg",
|
||||
"char-middle-aged-weathered-man" => $"{FallbackRoot}/character-graham.svg",
|
||||
"char-older-soft-presence" => $"{FallbackRoot}/character-maggie.svg",
|
||||
"char-young-helper" => $"{FallbackRoot}/character-beth.svg",
|
||||
"char-archival-contact" => $"{FallbackRoot}/character-graham.svg",
|
||||
"char-professional-investigator" => $"{FallbackRoot}/character-graham.svg",
|
||||
"char-neighbourhood-friend" => $"{FallbackRoot}/character-beth.svg",
|
||||
"char-family-relative" => $"{FallbackRoot}/character-maggie.svg",
|
||||
"char-quiet-antagonist" => $"{FallbackRoot}/character-graham.svg",
|
||||
"char-authorial-voice" => $"{FallbackRoot}/character-maggie.svg",
|
||||
"char-unknown-figure" => $"{FallbackRoot}/character-beth.svg",
|
||||
_ => $"{FallbackRoot}/character-beth.svg"
|
||||
};
|
||||
|
||||
@ -546,6 +654,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
return builder.ToString().Trim('-');
|
||||
}
|
||||
|
||||
private static int StableHash(string? value)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return BitConverter.ToInt32(bytes, 0) & int.MaxValue;
|
||||
}
|
||||
|
||||
private static string StableContentHash(string? value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)))[..16].ToLowerInvariant();
|
||||
|
||||
private static string Clean(string? value)
|
||||
=> string.Join(' ', (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Trim();
|
||||
|
||||
@ -561,5 +678,104 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "...";
|
||||
}
|
||||
|
||||
private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene);
|
||||
private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);
|
||||
|
||||
private sealed record CharacterCodeProfile(
|
||||
string Code,
|
||||
string AgeBand,
|
||||
string Presentation,
|
||||
string HairColour,
|
||||
string HairLength,
|
||||
IReadOnlyList<string> Signals)
|
||||
{
|
||||
public int Score(CharacterProfile profile)
|
||||
{
|
||||
var score = 0;
|
||||
if (profile.AgeBand == AgeBand) score += 80;
|
||||
else if (CompatibleAge(profile.AgeBand, AgeBand)) score += 30;
|
||||
else score -= 45;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(profile.Presentation) && Presentation.Equals(profile.Presentation, StringComparison.OrdinalIgnoreCase)) score += 24;
|
||||
if (!string.IsNullOrWhiteSpace(profile.HairColour) && HairColour.Equals(profile.HairColour, StringComparison.OrdinalIgnoreCase)) score += 18;
|
||||
if (!string.IsNullOrWhiteSpace(profile.HairLength) && HairLength.Equals(profile.HairLength, StringComparison.OrdinalIgnoreCase)) score += 10;
|
||||
score += Signals.Count(signal => profile.Text.Contains(signal, StringComparison.OrdinalIgnoreCase)) * 14;
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool CompatibleAge(string required, string candidate)
|
||||
=> (required, candidate) switch
|
||||
{
|
||||
("Teen", "YoungAdult") => true,
|
||||
("YoungAdult", "Teen") => true,
|
||||
("MiddleAged", "Older") => true,
|
||||
("Older", "MiddleAged") => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record CharacterProfile(string Text, string AgeBand, string Presentation, string HairColour, string HairLength)
|
||||
{
|
||||
public static CharacterProfile From(string name, SceneIntelligenceCharacter? character, SceneIntelligenceScene? scene)
|
||||
{
|
||||
var relationshipText = string.Join(' ', (scene?.Relationships ?? [])
|
||||
.Where(relationship => string.Equals(Clean(relationship.CharacterA), Clean(name), StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(Clean(relationship.CharacterB), Clean(name), StringComparison.OrdinalIgnoreCase))
|
||||
.Select(relationship => $"{relationship.RelationshipSignal} {relationship.Evidence}"));
|
||||
var text = $"{name} {character?.RoleInScene} {character?.Notes} {string.Join(' ', character?.Actions ?? [])} {relationshipText}".ToLowerInvariant();
|
||||
return new(
|
||||
text,
|
||||
DetectAgeBand(text),
|
||||
DetectPresentation(text),
|
||||
DetectHairColour(text),
|
||||
DetectHairLength(text));
|
||||
}
|
||||
|
||||
private static string DetectAgeBand(string text)
|
||||
{
|
||||
if (text.Contains("grandmother") || text.Contains("grandfather") || text.Contains("elderly") || text.Contains("older") || text.Contains("retired")) return "Older";
|
||||
if (text.Contains("mother") || text.Contains("father") || text.Contains("parent") || text.Contains("teacher") || text.Contains("aunt") || text.Contains("uncle") || text.Contains("middle-aged") || text.Contains("middle aged")) return "MiddleAged";
|
||||
if (text.Contains("teen") || text.Contains("teenage") || text.Contains("15") || text.Contains("fifteen") || text.Contains("schoolgirl") || text.Contains("schoolboy") || text.Contains("child") || text.Contains("daughter") || text.Contains("son")) return "Teen";
|
||||
if (text.Contains("young") || text.Contains("student")) return "YoungAdult";
|
||||
return "YoungAdult";
|
||||
}
|
||||
|
||||
private static string DetectPresentation(string text)
|
||||
{
|
||||
if (text.Contains("mother") || text.Contains("daughter") || text.Contains("woman") || text.Contains("girl") || text.Contains("female") || text.Contains("sister") || text.Contains("aunt")) return "Feminine";
|
||||
if (text.Contains("father") || text.Contains("son") || text.Contains("man") || text.Contains("male") || text.Contains("brother") || text.Contains("uncle")) return "Masculine";
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string DetectHairColour(string text)
|
||||
{
|
||||
if (text.Contains("red") || text.Contains("auburn") || text.Contains("ginger")) return "Red";
|
||||
if (text.Contains("grey") || text.Contains("gray") || text.Contains("white-haired") || text.Contains("white hair")) return "Grey";
|
||||
if (text.Contains("black hair") || text.Contains("black-haired")) return "Black";
|
||||
if (text.Contains("dark") || text.Contains("brown") || text.Contains("brunette")) return "Brown";
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string DetectHairLength(string text)
|
||||
{
|
||||
if (text.Contains("long hair") || text.Contains("long-haired")) return "Long";
|
||||
if (text.Contains("short hair") || text.Contains("short-haired")) return "Short";
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyList<CharacterCodeProfile> CharacterCodeProfiles =
|
||||
[
|
||||
new("char-young-helper", "Teen", "Androgynous", "Black", "Short", ["teen", "child", "daughter", "son", "helper"]),
|
||||
new("char-young-adult-red-haired-witness", "YoungAdult", "Feminine", "Red", "Long", ["red", "auburn", "witness", "protective"]),
|
||||
new("char-young-adult-brunette-observer", "YoungAdult", "Feminine", "Brown", "Medium", ["young", "observer", "student"]),
|
||||
new("char-neighbourhood-friend", "YoungAdult", "Androgynous", "Brown", "Medium", ["friend", "neighbour", "neighbor"]),
|
||||
new("char-family-relative", "MiddleAged", "Feminine", "Brown", "Medium", ["mother", "parent", "aunt", "family", "relative"]),
|
||||
new("char-professional-investigator", "MiddleAged", "Androgynous", "Brown", "Short", ["teacher", "professional", "investigator", "authority"]),
|
||||
new("char-middle-aged-weathered-man", "MiddleAged", "Masculine", "DarkBrown", "Short", ["father", "man", "suspect", "threat", "weathered"]),
|
||||
new("char-quiet-antagonist", "MiddleAged", "Androgynous", "DarkBrown", "Short", ["antagonist", "threat", "pressure", "controlled"]),
|
||||
new("char-older-soft-presence", "Older", "Feminine", "Grey", "Short", ["grandmother", "older", "remembered", "mother"]),
|
||||
new("char-archival-contact", "Older", "Masculine", "White", "Short", ["grandfather", "older", "archive", "records"]),
|
||||
new("char-authorial-voice", "Older", "Androgynous", "Grey", "Medium", ["voice", "memory", "recorded"]),
|
||||
new("char-unknown-figure", "YoungAdult", "Androgynous", "Brown", "Medium", ["unknown", "unidentified"])
|
||||
];
|
||||
}
|
||||
|
||||
@ -9,12 +9,29 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel
|
||||
public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow;
|
||||
public bool IsTerminal { get; init; }
|
||||
public string? SnapshotMessage { get; init; }
|
||||
public StoryIntelligenceExperienceSnapshotDiagnostics Diagnostics { get; init; } = new();
|
||||
public IReadOnlyList<StoryIntelligenceExperienceRunOption> AvailableRuns { get; init; } = [];
|
||||
public string ProjectName { get; init; } = string.Empty;
|
||||
public string BookTitle { get; init; } = string.Empty;
|
||||
public IReadOnlyList<StoryIntelligenceExperienceSceneState> Scenes { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
|
||||
{
|
||||
public string RunStatus { get; init; } = string.Empty;
|
||||
public int PersistedCompletedSceneCount { get; init; }
|
||||
public int TotalPersistedSceneResultCount { get; init; }
|
||||
public int SuccessfullyParsedSceneResultCount { get; init; }
|
||||
public int FailedSceneResultCount { get; init; }
|
||||
public int? HighestPersistedSceneResultId { get; init; }
|
||||
public int? HighestParsedSceneResultId { get; init; }
|
||||
public int? SelectedCurrentSceneResultId { get; init; }
|
||||
public string SelectedChapterAndSceneOrder { get; init; } = string.Empty;
|
||||
public string CurrentChangeToken { get; init; } = string.Empty;
|
||||
public string? AnalysisCursor { get; init; }
|
||||
public string? Warning { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceExperienceRunOption
|
||||
{
|
||||
public int RunId { get; init; }
|
||||
|
||||
@ -189,6 +189,74 @@
|
||||
<dt>Operations detected</dt>
|
||||
<dd data-diagnostics-operations>InitialRender</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Run status</dt>
|
||||
<dd data-diagnostic-key="runStatus">@Model.Diagnostics.RunStatus</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Persisted completed scenes</dt>
|
||||
<dd data-diagnostic-key="persistedCompletedSceneCount">@Model.Diagnostics.PersistedCompletedSceneCount</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Total persisted scene results</dt>
|
||||
<dd data-diagnostic-key="totalPersistedSceneResultCount">@Model.Diagnostics.TotalPersistedSceneResultCount</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Successfully parsed scene results</dt>
|
||||
<dd data-diagnostic-key="successfullyParsedSceneResultCount">@Model.Diagnostics.SuccessfullyParsedSceneResultCount</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Failed/unparseable scene results</dt>
|
||||
<dd data-diagnostic-key="failedSceneResultCount">@Model.Diagnostics.FailedSceneResultCount</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Highest persisted SceneResultID</dt>
|
||||
<dd data-diagnostic-key="highestPersistedSceneResultId">@Model.Diagnostics.HighestPersistedSceneResultId</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Highest parsed SceneResultID</dt>
|
||||
<dd data-diagnostic-key="highestParsedSceneResultId">@Model.Diagnostics.HighestParsedSceneResultId</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Selected current SceneResultID</dt>
|
||||
<dd data-diagnostic-key="selectedCurrentSceneResultId">@Model.Diagnostics.SelectedCurrentSceneResultId</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Selected chapter and scene order</dt>
|
||||
<dd data-diagnostic-key="selectedChapterAndSceneOrder">@Model.Diagnostics.SelectedChapterAndSceneOrder</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Analysis cursor</dt>
|
||||
<dd data-diagnostic-key="analysisCursor">@Model.Diagnostics.AnalysisCursor</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Snapshot warning</dt>
|
||||
<dd data-diagnostic-key="warning">@Model.Diagnostics.Warning</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last successful snapshot fetch</dt>
|
||||
<dd data-diagnostic-key="lastSuccessfulSnapshotFetch">Initial</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last snapshot HTTP status</dt>
|
||||
<dd data-diagnostic-key="lastSnapshotHttpStatus">Initial</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Consecutive unchanged polls</dt>
|
||||
<dd data-diagnostic-key="consecutiveUnchangedPolls">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Consecutive failed polls</dt>
|
||||
<dd data-diagnostic-key="consecutiveFailedPolls">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Polling</dt>
|
||||
<dd data-diagnostic-key="pollingActive">Initialising</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Polling stopped reason</dt>
|
||||
<dd data-diagnostic-key="pollingStoppedReason">none</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
@ -22,6 +22,11 @@
|
||||
polling: false,
|
||||
changeToken: model.changeToken || root.dataset.changeToken || "",
|
||||
terminal: model.isTerminal || root.dataset.terminal === "true",
|
||||
pollingStoppedReason: "",
|
||||
lastSnapshotHttpStatus: "initial",
|
||||
consecutiveUnchangedPolls: 0,
|
||||
consecutiveFailedPolls: 0,
|
||||
lastSuccessfulSnapshotFetch: "Initial",
|
||||
currentSnapshot: model,
|
||||
previousSnapshot: null,
|
||||
currentVersion: 1,
|
||||
@ -79,7 +84,8 @@
|
||||
diagnosticsQueue: root.querySelector("[data-diagnostics-queue]"),
|
||||
diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"),
|
||||
diagnosticsBuild: root.querySelector("[data-diagnostics-build]"),
|
||||
diagnosticsOperations: root.querySelector("[data-diagnostics-operations]")
|
||||
diagnosticsOperations: root.querySelector("[data-diagnostics-operations]"),
|
||||
diagnosticValues: root.querySelectorAll("[data-diagnostic-key]")
|
||||
};
|
||||
|
||||
function loadSceneState(index) {
|
||||
@ -138,7 +144,7 @@
|
||||
text(book, model.bookTitle);
|
||||
}
|
||||
transitionToNextState(state.index, operations);
|
||||
if (state.terminal) stopPolling();
|
||||
if (state.terminal) stopPolling("terminal model received");
|
||||
}
|
||||
|
||||
function updateProgress(scene) {
|
||||
@ -1081,14 +1087,16 @@
|
||||
function startPolling() {
|
||||
if (!realMode || !snapshotUrl || state.terminal) return;
|
||||
stopPolling();
|
||||
state.pollingStoppedReason = "";
|
||||
state.pollTimer = window.setInterval(pollSnapshot, document.hidden ? 15000 : 7000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
function stopPolling(reason) {
|
||||
if (state.pollTimer) {
|
||||
window.clearInterval(state.pollTimer);
|
||||
state.pollTimer = null;
|
||||
}
|
||||
if (reason) state.pollingStoppedReason = reason;
|
||||
}
|
||||
|
||||
async function pollSnapshot() {
|
||||
@ -1097,17 +1105,30 @@
|
||||
const requestStarted = performance.now();
|
||||
try {
|
||||
const response = await fetch(snapshotUrl, { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) return;
|
||||
state.lastSnapshotHttpStatus = String(response.status);
|
||||
if (!response.ok) {
|
||||
state.consecutiveFailedPolls += 1;
|
||||
updateDiagnostics();
|
||||
return;
|
||||
}
|
||||
const snapshot = await response.json();
|
||||
state.consecutiveFailedPolls = 0;
|
||||
state.lastSuccessfulSnapshotFetch = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
if (snapshot.changeToken && snapshot.changeToken !== state.changeToken) {
|
||||
state.consecutiveUnchangedPolls = 0;
|
||||
replaceModel(snapshot, Math.round(performance.now() - requestStarted));
|
||||
} else if (snapshot.isTerminal) {
|
||||
state.terminal = true;
|
||||
stopPolling();
|
||||
stopPolling("terminal snapshot received");
|
||||
updateDiagnostics();
|
||||
} else {
|
||||
state.consecutiveUnchangedPolls += 1;
|
||||
updateDiagnostics();
|
||||
}
|
||||
} catch {
|
||||
// Development snapshot polling is best-effort.
|
||||
state.lastSnapshotHttpStatus = "fetch failed";
|
||||
state.consecutiveFailedPolls += 1;
|
||||
updateDiagnostics();
|
||||
} finally {
|
||||
state.polling = false;
|
||||
}
|
||||
@ -1226,6 +1247,23 @@
|
||||
text(dom.diagnosticsRefresh, state.lastRefreshTime);
|
||||
text(dom.diagnosticsBuild, `${state.snapshotBuildMs}ms`);
|
||||
text(dom.diagnosticsOperations, state.lastOperations.join(", "));
|
||||
const diagnostics = model.diagnostics || {};
|
||||
dom.diagnosticValues.forEach((node) => {
|
||||
const key = node.dataset.diagnosticKey;
|
||||
const value = pollingDiagnosticValue(key, diagnostics);
|
||||
text(node, value);
|
||||
});
|
||||
}
|
||||
|
||||
function pollingDiagnosticValue(key, diagnostics) {
|
||||
if (key === "lastSuccessfulSnapshotFetch") return state.lastSuccessfulSnapshotFetch;
|
||||
if (key === "lastSnapshotHttpStatus") return state.lastSnapshotHttpStatus;
|
||||
if (key === "consecutiveUnchangedPolls") return state.consecutiveUnchangedPolls;
|
||||
if (key === "consecutiveFailedPolls") return state.consecutiveFailedPolls;
|
||||
if (key === "pollingActive") return state.pollTimer ? "active" : "stopped";
|
||||
if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none";
|
||||
const value = diagnostics[key];
|
||||
return value === null || value === undefined || value === "" ? "none" : value;
|
||||
}
|
||||
|
||||
function animateNumber(node, nextValue, suffix) {
|
||||
@ -1362,4 +1400,5 @@
|
||||
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
|
||||
startTimer();
|
||||
startPolling();
|
||||
if (realMode && !state.terminal) pollSnapshot();
|
||||
})();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user